From ab0f20e0216995978b8a36bc89818c3947955bc7 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 22 Dec 2025 02:12:27 +0300 Subject: [PATCH] Add copy to clipboard component --- .vscode/settings.json | 1 + _TODO.md | 136 +++++------ astro.config.ts | 4 + package-lock.json | 9 + package.json | 3 +- .../Copy/client/__tests__/index.spec.ts | 221 ++++++++++++++++++ src/components/Copy/client/index.ts | 123 ++++++++++ src/components/Copy/client/utils.ts | 77 ++++++ src/components/Copy/index.astro | 74 ++++++ .../__tests__/webComponent.spec.ts | 4 +- .../Test/{ => client}/webComponent.ts | 0 src/components/Test/webComponent.astro | 2 +- src/content/articles/demo/index.mdx | 94 +++++++- src/icons/check.svg | 2 +- src/icons/copy.svg | 4 + src/layouts/MarkdownLayout.astro | 3 +- 16 files changed, 668 insertions(+), 89 deletions(-) create mode 100644 src/components/Copy/client/__tests__/index.spec.ts create mode 100644 src/components/Copy/client/index.ts create mode 100644 src/components/Copy/client/utils.ts create mode 100644 src/components/Copy/index.astro rename src/components/Test/{ => client}/__tests__/webComponent.spec.ts (95%) rename src/components/Test/{ => client}/webComponent.ts (100%) create mode 100644 src/icons/copy.svg 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: [![An old rock in the desert](/assets/images/shiprock.svg "Shiprock placeholder")](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 @@ check mark icon - + 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 @@ + + copy icon + + \ 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 */