diff --git a/.vscode/settings.json b/.vscode/settings.json
index 64b80fda9..86fe6a61c 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -84,6 +84,7 @@
"Postg",
"postgrest",
"psql",
+ "pylint",
"qpart",
"qparts",
"Qualys",
diff --git a/_TODO.md b/_TODO.md
index 23cbe1e1c..91c8ccca7 100644
--- a/_TODO.md
+++ b/_TODO.md
@@ -69,60 +69,9 @@ If we want to make it feel less inconsistent, we could either (a) rename `_logge
- CallToAction/Newsletter
- ContactForm
-## Mobile Social Shares UI
-
-See the example image in Social Shares. The social shares UI on mobile should be a modal that slides in from the bottom.
-
-## Performance
-
-Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md
-
-## Analytics
-
-Vercel Analytics
-
-- Highlighter component
-- Social Shares component
-- Social Embeds: Track embed interactions
-- Cookie Consent
-- Download Form component
-
-npm i @vercel/analytics
-import Analytics from '@vercel/analytics/astro'
-https://vercel.com/docs/analytics/quickstart#add-the-analytics-component-to-your-app
-
-## Themepicker tooltips, extra themes
-
-- Add additional themes
-- Add tooltip that makes use of the description field for the theme, explaining what the intent of the theme is
-
-## Sentry feedback, chat bot tying into my phone and email
-
-See note in src/components/scripts/sentry/client.ts - "User Feedback - allow users to report issues"
-
-https://vercel.com/kevin-browns-projects-dd474f73/astro-webstackbuilders-com/ai-gateway
-
-## Uppy file uploads from contact form
-
-docs/CONTACT_FORM.md
+## Refactor Theme Colors
-Where to upload to?
-
-## Search
-
-Add Upstash Search as a Vercel Marketplace Integration.
-
-Lunr is a JS search library using an inverted index. Client-side search for statically hosted pages.
-
-### [`@jackcarey/astro-lunr`](https://www.npmjs.com/package/@jackcarey/astro-lunr)
-
-### [`@siverv/astro-lunr`](https://www.npmjs.com/package/@siverv/astro-lunr)
-
-## Email Templates
-
-Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo.
-
-## Color vars
+### Color vars
brand primary: #001733
brand secondary: #0062B6
@@ -132,7 +81,9 @@ accent
text-white, other default Tailwind colors
-## Files with Skipped Tests
+## Refactor E2E Test Suite
+
+### Files with Skipped Tests
Blocked Categories (44 tests):
@@ -141,7 +92,7 @@ Lighthouse audits (6) - Integration pending
Newsletter double opt-in (6) - Email testing infrastructure
Axe accessibility (2) - axe-core integration
-## Axe tags
+### Axe tags
cat.aria: Rules related to Accessible Rich Internet Applications (ARIA) attributes and roles.
cat.color: Rules related to color contrast and meaning conveyed by color.
@@ -165,20 +116,6 @@ Needs to add real API key and test
- (Optional) Set up Bridgy for social media
- Test with sample webmentions
-## Custom Directives
-
-[`astro-directives`](https://github.com/QuentinDutot/astro-directives)
-
-```react
-
-```
-
-| Attribute | Load the javascript and hydrate on ... |
-| ------------- | -------------------------------------- |
-| client:click | element click event |
-| client:hover | element mouseover event |
-| client:scroll | window scroll event |
-
## Prefetch Links
The default prefetch strategy when adding the data-astro-prefetch attribute is hover. To change it, you can configure prefetch.defaultStrategy in your astro.config.mjs file.
@@ -212,6 +149,59 @@ You can then opt-out of prefetching for individual links by setting data-astro-p
About
```
+## Mobile Social Shares UI
+
+See the example image in Social Shares. The social shares UI on mobile should be a modal that slides in from the bottom.
+
+## Performance
+
+Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md
+
+## Analytics
+
+Vercel Analytics
+
+- Highlighter component
+- Social Shares component
+- Social Embeds: Track embed interactions
+- Cookie Consent
+- Download Form component
+
+npm i @vercel/analytics
+import Analytics from '@vercel/analytics/astro'
+https://vercel.com/docs/analytics/quickstart#add-the-analytics-component-to-your-app
+
+## Themepicker tooltips, extra themes
+
+- Add additional themes
+- Add tooltip that makes use of the description field for the theme, explaining what the intent of the theme is
+
+## Sentry feedback, chat bot tying into my phone and email
+
+See note in src/components/scripts/sentry/client.ts - "User Feedback - allow users to report issues"
+
+https://vercel.com/kevin-browns-projects-dd474f73/astro-webstackbuilders-com/ai-gateway
+
+## Uppy file uploads from contact form
+
+docs/CONTACT_FORM.md
+
+Where to upload to?
+
+## Search
+
+Add Upstash Search as a Vercel Marketplace Integration.
+
+Lunr is a JS search library using an inverted index. Client-side search for statically hosted pages.
+
+### [`@jackcarey/astro-lunr`](https://www.npmjs.com/package/@jackcarey/astro-lunr)
+
+### [`@siverv/astro-lunr`](https://www.npmjs.com/package/@siverv/astro-lunr)
+
+## Email Templates
+
+Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo.
+
## Astro Components to Add
### "Add to Calendar" button
@@ -221,14 +211,6 @@ Google Calendar, Apple Calendar, Yahoo Calender, Microsoft 365, Outlook, and T
`https://github.com/add2cal/add-to-calendar-button`
`https://add-to-calendar-button.com/`
-### Astro wrapper for the `@github/clipboard-copy-element` web component. Copies element text content or input values to the clipboard
-
-[`clipboard-copy`](https://github.com/BryceRussell/astro-github-elements/tree/main/packages/clipboard-copy#astro-github-elementsclipboard-copy)
-
-### Astro wrapper for GitHub's relative time web component. Translates dates to past or future time phrases, like "*4 hours from now*" or "*20 days ago*"
-
-[Relative Time](https://github.com/BryceRussell/astro-github-elements/tree/main/packages/time#readme)
-
## Markdown
## Code Block and Highlighting
diff --git a/astro.config.ts b/astro.config.ts
index e7a95ad6a..9a2d29bb1 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -1,5 +1,6 @@
import AstroPWA from '@vite-pwa/astro'
import db from '@astrojs/db'
+import directives from 'astro-directives'
import icon from 'astro-icon'
import linkValidator from 'astro-link-validator'
import lit from '@semantic-ui/astro-lit'
@@ -51,6 +52,9 @@ const standardIntegrations = [
/** Astro DB - uses Tursa for backing store in production */
db(),
...sharedTestIntegrations,
+ /**
+ * Adds client:click, client:hover, and client:scroll custom client directives for components */
+ directives(),
mdx(markdownConfig),
/** Generate favicons and PWA icons from source SVG */
faviconGenerator(),
diff --git a/package-lock.json b/package-lock.json
index 8ba171933..671506f03 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,6 +53,7 @@
"@vite-pwa/astro": "^1.2.0",
"@webcomponents/template-shadowroot": "^0.2.1",
"astro": "5.16.6",
+ "astro-directives": "^1.0.7",
"astro-icon": "^1.1.5",
"astro-link-validator": "github:rodgtr1/astro-link-validator",
"astro-og-canvas": "^0.7.2",
@@ -14484,6 +14485,14 @@
"sharp": "^0.34.0"
}
},
+ "node_modules/astro-directives": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/astro-directives/-/astro-directives-1.0.7.tgz",
+ "integrity": "sha512-X8dt6QSuIJOdLxqdatyo3hSIAZY5x0R5rx/IymJ0+8a/z9UZAXrFz2rs5I874g6o2s5KTxzbEOU9PAn5dxDhjg==",
+ "peerDependencies": {
+ "astro": "^2.5.0"
+ }
+ },
"node_modules/astro-eslint-parser": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/astro-eslint-parser/-/astro-eslint-parser-1.2.2.tgz",
diff --git a/package.json b/package.json
index 334fd6c76..6d2e95f1f 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
"lint:actions": "FORCE_COLOR=1 npx node-actionlint && FORCE_COLOR=1 python3 -m pylint $(find .github/actions -type f -path '*/src/*.py')",
"lint:code": "npx eslint \"@types/**/*.{js,ts}\" \"src/**/*.{js,ts,tsx,astro}\" \"test/**/*.{js,ts,tsx,astro}\"",
"lint:inclusive-language": "npx alex src/content",
- "lint:json": "FORCE_COLOR=1 npx prettier '**/*.json' --cache --ignore-path .gitignore",
+ "lint:json": "FORCE_COLOR=1 npx prettier \"**/*.json\" --cache --check --ignore-path .gitignore --ignore-path .prettierignore",
"lint:md": "FORCE_COLOR=1 npx markdownlint-cli2 \"**/*.{md,mdx}\" \"!**/node_modules/**\" \"!**/dist/**\" \"!**/.astro/**\" \"!**/dev-dist/**\" \"!**/__blobstorage__/**\"",
"lint:style": "FORCE_COLOR=1 npx stylelint \"src/**/*.{css,astro}\"",
"lint:tsc:check": "npm run sync && tsc --noEmit -p tsconfig.json --pretty false",
@@ -102,6 +102,7 @@
"@vite-pwa/astro": "^1.2.0",
"@webcomponents/template-shadowroot": "^0.2.1",
"astro": "5.16.6",
+ "astro-directives": "^1.0.7",
"astro-icon": "^1.1.5",
"astro-link-validator": "github:rodgtr1/astro-link-validator",
"astro-og-canvas": "^0.7.2",
diff --git a/src/components/Copy/client/__tests__/index.spec.ts b/src/components/Copy/client/__tests__/index.spec.ts
new file mode 100644
index 000000000..1eb886f98
--- /dev/null
+++ b/src/components/Copy/client/__tests__/index.spec.ts
@@ -0,0 +1,221 @@
+// @vitest-environment node
+
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { experimental_AstroContainer as AstroContainer } from 'astro/container'
+import CopyAstro from '@components/Copy/index.astro'
+import type { CopyToClipboardElement as CopyToClipboardInstance } from '@components/Copy/client'
+import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
+import { executeRender } from '@test/unit/helpers/litRuntime'
+
+type CopyComponentModule = WebComponentModule
+
+describe('CopyToClipboardElement', () => {
+ let container: AstroContainer
+
+ beforeEach(async () => {
+ container = await AstroContainer.create()
+ })
+
+ const runComponentRender = async (
+ args: Parameters[1],
+ assertion: (_context: { element: CopyToClipboardInstance; window: Window }) => Promise | void,
+ ): Promise => {
+ await executeRender({
+ container,
+ component: CopyAstro,
+ moduleSpecifier: '@components/Copy/client/index',
+ args,
+ waitForReady: async (element: CopyToClipboardInstance) => {
+ await vi.waitFor(() => {
+ const button = element.querySelector('[data-copy-to-clipboard-button]') as HTMLButtonElement | null
+ expect(button).toBeTruthy()
+ expect(button?.dataset['copyToClipboardListener']).toBe('true')
+ })
+ },
+ assert: async ({ element, window }) => {
+ await assertion({ element, window: window as unknown as Window })
+ },
+ })
+ }
+
+ test('copies value attribute to clipboard and emits clipboard-copy event', async () => {
+ await runComponentRender(
+ {
+ props: {
+ value: 'hello world',
+ },
+ slots: {
+ default: 'Copy',
+ },
+ },
+ async ({ element, window }) => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(window.navigator, 'clipboard', {
+ value: { writeText },
+ configurable: true,
+ })
+
+ const copied = vi.fn()
+ element.addEventListener('clipboard-copy', copied)
+
+ const button = element.querySelector('[data-copy-to-clipboard-button]') as HTMLButtonElement | null
+ expect(button).toBeTruthy()
+
+ button?.click()
+ await vi.waitFor(() => {
+ expect(writeText).toHaveBeenCalledWith('hello world')
+ })
+
+ await vi.waitFor(() => {
+ expect(copied).toHaveBeenCalledOnce()
+ })
+
+ await vi.waitFor(() => {
+ const copyIcon = element.querySelector('[data-copy-to-clipboard-icon]') as HTMLElement | null
+ const successIcon = element.querySelector('[data-copy-to-clipboard-success-icon]') as HTMLElement | null
+ expect(copyIcon?.hasAttribute('hidden')).toBe(true)
+ expect(successIcon?.hasAttribute('hidden')).toBe(false)
+ })
+ },
+ )
+ })
+
+ test('copies target element text when for= is provided', async () => {
+ await runComponentRender(
+ {
+ props: {
+ for: 'copy-target',
+ },
+ slots: {
+ default: 'Copy',
+ },
+ },
+ async ({ element, window }) => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(window.navigator, 'clipboard', {
+ value: { writeText },
+ configurable: true,
+ })
+
+ const target = window.document.createElement('div')
+ target.id = 'copy-target'
+ target.innerHTML = 'Hello world!'
+ window.document.body.appendChild(target)
+
+ expect(window.document.getElementById('copy-target')?.textContent).toBe('Hello world!')
+
+ const button = element.querySelector('[data-copy-to-clipboard-button]') as HTMLButtonElement | null
+ expect(button).toBeTruthy()
+ expect(button?.getAttribute('for')).toBe('copy-target')
+
+ writeText.mockClear()
+
+ button?.click()
+ await vi.waitFor(() => {
+ expect(writeText).toHaveBeenCalledWith('Hello world!')
+ })
+ },
+ )
+ })
+
+ test('does not copy when clicking slotted content (icon-only click target)', async () => {
+ await runComponentRender(
+ {
+ props: {
+ value: 'hello world',
+ },
+ slots: {
+ default: 'Copy',
+ },
+ },
+ async ({ element, window }) => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(window.navigator, 'clipboard', {
+ value: { writeText },
+ configurable: true,
+ })
+
+ const content = element.querySelector('[data-copy-to-clipboard-content]') as HTMLElement | null
+ expect(content).toBeTruthy()
+
+ const button = element.querySelector('[data-copy-to-clipboard-button]') as HTMLButtonElement | null
+ expect(button).toBeTruthy()
+
+ content?.click()
+ await Promise.resolve()
+ expect(writeText).not.toHaveBeenCalled()
+
+ button?.click()
+ await vi.waitFor(() => {
+ expect(writeText).toHaveBeenCalledWith('hello world')
+ })
+ },
+ )
+ })
+
+ test('mode=float adds group wrapper and hides icon button until hover/focus', async () => {
+ await runComponentRender(
+ {
+ props: {
+ value: 'hello world',
+ mode: 'float',
+ },
+ slots: {
+ default: 'Copy',
+ },
+ },
+ async ({ element }) => {
+ const wrapper = element.querySelector('[data-copy-to-clipboard-wrapper]') as HTMLElement | null
+ expect(wrapper).toBeTruthy()
+ expect(wrapper?.className).toContain('group')
+
+ const button = element.querySelector('[data-copy-to-clipboard-button]') as HTMLButtonElement | null
+ expect(button).toBeTruthy()
+
+ const buttonClass = button?.getAttribute('class') ?? ''
+ expect(buttonClass).toContain('opacity-0')
+ expect(buttonClass).toContain('pointer-events-none')
+ expect(buttonClass).toContain('group-hover:opacity-100')
+ expect(buttonClass).toContain('group-focus-within:opacity-100')
+ },
+ )
+ })
+
+ test('styles prop applies classes to wrapper, button, and icons', async () => {
+ await runComponentRender(
+ {
+ props: {
+ value: 'hello world',
+ styles: {
+ wrapper: 'my-wrapper',
+ button: 'my-button',
+ icon: 'my-icon',
+ successIcon: 'my-success-icon',
+ },
+ },
+ slots: {
+ default: 'Copy',
+ },
+ },
+ async ({ element }) => {
+ const wrapper = element.querySelector('[data-copy-to-clipboard-wrapper]') as HTMLElement | null
+ expect(wrapper).toBeTruthy()
+ expect(wrapper?.className).toContain('my-wrapper')
+
+ const button = element.querySelector('[data-copy-to-clipboard-button]') as HTMLButtonElement | null
+ expect(button).toBeTruthy()
+ expect(button?.className).toContain('my-button')
+
+ const copySvg = element
+ .querySelector('[data-copy-to-clipboard-icon] svg')
+ ?.getAttribute('class')
+ expect(copySvg ?? '').toContain('my-icon')
+
+ const successSvg = element
+ .querySelector('[data-copy-to-clipboard-success-icon] svg')
+ ?.getAttribute('class')
+ expect(successSvg ?? '').toContain('my-success-icon')
+ },
+ )
+ })
+})
diff --git a/src/components/Copy/client/index.ts b/src/components/Copy/client/index.ts
new file mode 100644
index 000000000..5b4399f4b
--- /dev/null
+++ b/src/components/Copy/client/index.ts
@@ -0,0 +1,123 @@
+import { LitElement } from 'lit'
+import { defineCustomElement } from '@components/scripts/utils'
+import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
+import { addButtonEventListeners } from '@components/scripts/elementListeners'
+import { copyFromElement } from './utils'
+
+export class CopyToClipboardElement extends LitElement {
+ static registeredName = 'copy-to-clipboard'
+
+ private button: HTMLButtonElement | undefined
+ private copyIcon: HTMLElement | undefined
+ private successIcon: HTMLElement | undefined
+ private resetTimerId: number | undefined
+
+ protected override createRenderRoot(): HTMLElement {
+ return this
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback()
+
+ queueMicrotask(() => {
+ this.cacheElements()
+ this.bindEvents()
+ this.resetIcons()
+ })
+ }
+
+ override disconnectedCallback(): void {
+ super.disconnectedCallback()
+ if (this.resetTimerId) {
+ window.clearTimeout(this.resetTimerId)
+ this.resetTimerId = undefined
+ }
+ }
+
+ private cacheElements(): void {
+ const button = this.querySelector('[data-copy-to-clipboard-button]')
+ this.button = button instanceof HTMLButtonElement ? button : undefined
+
+ const copyIcon = this.querySelector('[data-copy-to-clipboard-icon]')
+ this.copyIcon = copyIcon instanceof HTMLElement ? copyIcon : undefined
+
+ const successIcon = this.querySelector('[data-copy-to-clipboard-success-icon]')
+ this.successIcon = successIcon instanceof HTMLElement ? successIcon : undefined
+ }
+
+ private bindEvents(): void {
+ if (!this.button) {
+ return
+ }
+
+ if (!this.button.dataset['copyToClipboardListener']) {
+ addButtonEventListeners(this.button, this.handleCopy, this)
+ this.button.dataset['copyToClipboardListener'] = 'true'
+ }
+ }
+
+ private readonly handleCopy = async (event: Event): Promise => {
+ const target = event.currentTarget
+ if (!(target instanceof HTMLElement)) {
+ return
+ }
+
+ try {
+ const copiedText = await copyFromElement(target)
+ if (copiedText === null) {
+ return
+ }
+
+ this.dispatchEvent(
+ new CustomEvent('clipboard-copy', {
+ bubbles: true,
+ detail: {
+ text: copiedText,
+ },
+ }),
+ )
+
+ this.showSuccess()
+ } catch {
+ // Intentionally ignore clipboard failures; consumers can observe lack of event.
+ }
+ }
+
+ private resetIcons(): void {
+ if (this.copyIcon) {
+ this.copyIcon.removeAttribute('hidden')
+ }
+ if (this.successIcon) {
+ this.successIcon.setAttribute('hidden', '')
+ }
+ }
+
+ private showSuccess(): void {
+ if (this.resetTimerId) {
+ window.clearTimeout(this.resetTimerId)
+ }
+
+ if (this.copyIcon) {
+ this.copyIcon.setAttribute('hidden', '')
+ }
+ if (this.successIcon) {
+ this.successIcon.removeAttribute('hidden')
+ }
+
+ this.resetTimerId = window.setTimeout(() => {
+ this.resetIcons()
+ }, 1500)
+ }
+}
+
+export const registerWebComponent = async (tagName = CopyToClipboardElement.registeredName) => {
+ defineCustomElement(tagName, CopyToClipboardElement)
+}
+
+export const registerCopyToClipboard = registerWebComponent
+
+export const webComponentModule: WebComponentModule = {
+ registeredName: CopyToClipboardElement.registeredName,
+ componentCtor: CopyToClipboardElement,
+ registerWebComponent,
+}
diff --git a/src/components/Copy/client/utils.ts b/src/components/Copy/client/utils.ts
new file mode 100644
index 000000000..1c3b37cf3
--- /dev/null
+++ b/src/components/Copy/client/utils.ts
@@ -0,0 +1,77 @@
+import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
+
+export const copyFromElement = async (button: HTMLElement): Promise => {
+ const id = button.getAttribute('for')
+ const text = button.getAttribute('value')
+
+ if (button.getAttribute('aria-disabled') === 'true') {
+ return null
+ }
+
+ if (typeof text === 'string' && text.length > 0) {
+ await copyText(text)
+ return text
+ }
+
+ if (typeof id !== 'string' || id.length === 0) {
+ return null
+ }
+
+ const ownerDocument = button.ownerDocument
+ const root = typeof button.getRootNode === 'function' ? button.getRootNode() : ownerDocument
+
+ const rootWindow = ownerDocument.defaultView
+ const shadowRootCtor = rootWindow?.ShadowRoot
+ const isShadowRoot = Boolean(shadowRootCtor) && root instanceof (shadowRootCtor as unknown as typeof ShadowRoot)
+ const isOwnerDocument = root === ownerDocument
+
+ const nodeFromRoot = isOwnerDocument
+ ? ownerDocument.getElementById(id)
+ : isShadowRoot
+ ? (root as ShadowRoot).getElementById?.(id) ?? (root as ShadowRoot).querySelector(`#${id}`)
+ : null
+
+ const node = nodeFromRoot ?? ownerDocument.getElementById(id)
+ if (!node) {
+ return null
+ }
+
+ const copiedText = getCopyTextFromTarget(node)
+ await copyText(copiedText)
+ return copiedText
+}
+
+export const getCopyTextFromTarget = (content: Element): string => {
+ if (content instanceof HTMLInputElement || content instanceof HTMLTextAreaElement) {
+ return content.value
+ }
+
+ if (content instanceof HTMLAnchorElement && content.hasAttribute('href')) {
+ return content.href
+ }
+
+ return content.textContent || ''
+}
+
+export function copyText(text: string): Promise {
+ const clipboard = getClipboard()
+ if (clipboard) {
+ return clipboard.writeText(text)
+ }
+
+ return Promise.reject(new ClientScriptError('CopyToClipboard: Clipboard API is unavailable'))
+}
+
+const getClipboard = (): Navigator['clipboard'] | null => {
+ const nav = typeof window !== 'undefined' ? window.navigator : typeof navigator !== 'undefined' ? navigator : undefined
+ if (!nav || !('clipboard' in nav)) {
+ return null
+ }
+
+ const clipboard = nav.clipboard
+ if (!clipboard?.writeText) {
+ return null
+ }
+
+ return clipboard
+}
diff --git a/src/components/Copy/index.astro b/src/components/Copy/index.astro
new file mode 100644
index 000000000..85a6e530d
--- /dev/null
+++ b/src/components/Copy/index.astro
@@ -0,0 +1,74 @@
+---
+import Icon from '@components/Icon/index.astro'
+
+export interface Props {
+ class?: string
+ title?: string
+ mode?: 'fixed' | 'float'
+ styles?:
+ | string
+ | {
+ wrapper?: string
+ button?: string
+ icon?: string
+ successIcon?: string
+ }
+}
+
+const { class: className, title = 'Copy to clipboard', mode = 'fixed', styles, ...rest } = Astro.props
+
+const wrapperExtraClass = typeof styles === 'string' ? styles : styles?.wrapper
+const buttonExtraClass = typeof styles === 'string' ? undefined : styles?.button
+const iconExtraClass = typeof styles === 'string' ? undefined : styles?.icon
+const successIconExtraClass = typeof styles === 'string' ? undefined : styles?.successIcon
+
+const wrapperClass = ['inline-flex items-center gap-2', wrapperExtraClass].filter(Boolean).join(' ')
+const buttonClass = ['copy-to-clipboard-button', className, buttonExtraClass]
+ .filter(Boolean)
+ .join(' ')
+
+const floatButtonClass =
+ mode === 'float'
+ ? [
+ 'opacity-0 pointer-events-none',
+ 'group-hover:opacity-100 group-hover:pointer-events-auto',
+ 'group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
+ 'focus-visible:opacity-100 focus-visible:pointer-events-auto',
+ ].join(' ')
+ : ''
+
+const iconButtonColorClass = [
+ 'text-text fill-text',
+ 'hover:text-text-offset hover:fill-text-offset',
+ 'focus-visible:text-text-offset focus-visible:fill-text-offset',
+ 'cursor-pointer',
+].join(' ')
+---
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/Test/__tests__/webComponent.spec.ts b/src/components/Test/client/__tests__/webComponent.spec.ts
similarity index 95%
rename from src/components/Test/__tests__/webComponent.spec.ts
rename to src/components/Test/client/__tests__/webComponent.spec.ts
index 9f7b800ff..1c3de9aa7 100644
--- a/src/components/Test/__tests__/webComponent.spec.ts
+++ b/src/components/Test/client/__tests__/webComponent.spec.ts
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, test } from 'vitest'
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
import TestWebComponentAstro from '@components/Test/webComponent.astro'
-import type { TestWebComponent as TestWebComponentInstance } from '@components/Test/webComponent'
+import type { TestWebComponent as TestWebComponentInstance } from '../webComponent'
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
import { executeRender } from '@test/unit/helpers/litRuntime'
@@ -26,7 +26,7 @@ describe('TestWebComponent class behavior', () => {
await executeRender({
container,
component: TestWebComponentAstro,
- moduleSpecifier: '@components/Test/webComponent',
+ moduleSpecifier: '@components/Test/client/webComponent',
args: renderArgs,
waitForReady: async (element: TestWebComponentInstance) => {
await element.updateComplete
diff --git a/src/components/Test/webComponent.ts b/src/components/Test/client/webComponent.ts
similarity index 100%
rename from src/components/Test/webComponent.ts
rename to src/components/Test/client/webComponent.ts
diff --git a/src/components/Test/webComponent.astro b/src/components/Test/webComponent.astro
index fe575b4ad..416dbbd6e 100644
--- a/src/components/Test/webComponent.astro
+++ b/src/components/Test/webComponent.astro
@@ -15,6 +15,6 @@ const { heading = 'Test Web Component' } = Astro.props
diff --git a/src/content/articles/demo/index.mdx b/src/content/articles/demo/index.mdx
index c535ffbe2..acf6aa8a9 100644
--- a/src/content/articles/demo/index.mdx
+++ b/src/content/articles/demo/index.mdx
@@ -102,7 +102,7 @@ Showcase related articles using the production carousel component:
limit={3}
/>
-### Code Examples
+### Code Component
Here's a standard code block for reference:
@@ -121,13 +121,79 @@ const demo: DemoInterface = {
};
```
-### Contact Callout
+### Contact Callout Component
Invite readers to get in touch:
-### Featured Content
+### Copy To Clipboard Component
+
+#### Attribute
+
+```markdown
+Copy
+```
+
+Copy
+
+Float mode:
+
+```markdown
+Copy
+```
+
+Copy
+
+#### Element content
+
+```markdown
+
Copy
+```
+
+
Copy
+
+Float mode:
+
+```markdown
+
Copy
+```
+
+
Copy
+
+#### Form input
+
+```markdown
+
+```
+
+
+
+Float mode:
+
+```markdown
+
+```
+
+
+
+#### Hyperlink href
+
+```markdown
+Link text will not be copied
+```
+
+Link text will not be copied
+
+Float mode:
+
+```markdown
+Link text will not be copied
+```
+
+Link text will not be copied
+
+### Featured Content Component
Highlight featured articles or services:
@@ -155,7 +221,7 @@ Use SVG sprites for icons throughout your content:
-### Social Embeds
+### Social Embeds Component
#### YouTube Embed
@@ -199,13 +265,13 @@ Use SVG sprites for icons throughout your content:
url="https://gist.github.com/webstackdev/455fb0b4e0d2332a8aaa02309563fb54"
/>
-### Newsletter Signup
+### Newsletter Signup Component
Encourage readers to subscribe to your newsletter:
-### Social Sharing
+### Social Sharing Component
Add social sharing buttons for the article:
@@ -404,6 +470,22 @@ Linked image:
[](https://www.flickr.com/photos/beaurogers/31833779864/in/photolist-Qv3rFw-34mt9F-a9Cmfy-5Ha3Zi-9msKdv-o3hgjr-hWpUte-4WMsJ1-KUQ8N-deshUb-vssBD-6CQci6-8AFCiD-zsJWT-nNfsgB-dPDwZJ-bn9JGn-5HtSXY-6CUhAL-a4UTXB-ugPum-KUPSo-fBLNm-6CUmpy-4WMsc9-8a7D3T-83KJev-6CQ2bK-nNusHJ-a78rQH-nw3NvT-7aq2qf-8wwBso-3nNceh-ugSKP-4mh4kh-bbeeqH-a7biME-q3PtTf-brFpgb-cg38zw-bXMZc-nJPELD-f58Lmo-bXMYG-bz8AAi-bxNtNT-bXMYi-bXMY6-bXMYv)
+### `kbd`
+
+This is a default HTML markup, just adding it here to not forget to use it when needed:
+
+```markdown
+
+ You can also create a new document by pressing the
+ Ctrl+N shortcut.
+
+```
+
+
+ You can also create a new document by pressing the
+ Ctrl+N shortcut.
+
+
### Links
```markdown
diff --git a/src/icons/check.svg b/src/icons/check.svg
index f0b225083..c92b37be8 100644
--- a/src/icons/check.svg
+++ b/src/icons/check.svg
@@ -1,5 +1,5 @@
diff --git a/src/icons/copy.svg b/src/icons/copy.svg
new file mode 100644
index 000000000..26693b840
--- /dev/null
+++ b/src/icons/copy.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/layouts/MarkdownLayout.astro b/src/layouts/MarkdownLayout.astro
index 271ca25a3..33d028b40 100644
--- a/src/layouts/MarkdownLayout.astro
+++ b/src/layouts/MarkdownLayout.astro
@@ -17,6 +17,7 @@ import Avatar from '@components/Avatar/index.astro'
import Callout from '@components/Callout/index.astro'
import Carousel from '@components/Carousel/index.astro'
import Contact from '@components/CallToAction/Contact/index.astro'
+import Copy from '@components/Copy/index.astro'
import Embed from '@components/Social/Embed/index.astro'
import Featured from '@components/CallToAction/Featured/index.astro'
import Highlighter from '@components/Social/Highlighter/index.astro'
@@ -28,7 +29,7 @@ import Time from '@components/Time/index.astro'
import Testimonials from '@components/Testimonials/index.astro'
/** Export components for use in MDX */
-const Components = { Avatar, Callout, Carousel, Contact, Embed, Featured, Highlighter, Icon, Image, MastodonModal, Newsletter, Picture, Shares, Testimonials, Time }
+const Components = { Avatar, Callout, Carousel, Contact, Copy, Embed, Featured, Highlighter, Icon, Image, MastodonModal, Newsletter, Picture, Shares, Testimonials, Time }
export interface Props {
/** Author name */