diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index 7c84a5fee..21787ac60 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -1,9 +1,8 @@ -import { customElement, WebComponent } from '@/lib/components' +import { customElement, FormControlComponent, FormControlValue } from '@/lib/components' import { Task } from '@lit/task' import { html, nothing, TemplateResult, type PropertyValues } from 'lit' import { property, query, state } from 'lit/decorators.js' import { debounce } from '@/lib/timing' -import FormControlTrait from '@/lib/components/traits/FormControlTrait' import type ComboboxOption from '@/components/combobox-option/ComboboxOption' import '~icons/lucide/chevron-down' @@ -15,7 +14,7 @@ import styles from './Combobox.styles.css' class AsyncOptionsInfo extends Error {} export type ComboboxOptionData = { - value: unknown; + value: FormControlValue; label: string; template?: TemplateResult; selectable?: boolean; @@ -30,24 +29,8 @@ export function defineAsyncComboboxOptionsProvider (this.filter = value)) private asyncOptionsTask?: Task @@ -95,13 +77,6 @@ export default class Combobox extends WebComponent { constructor () { super() - this.controlTrait = this.addTrait( - new FormControlTrait(this, { - getControlElement: () => this.inputElement, - getInternals: () => this.getInternals(), - }) - ) - this.listboxId = `listbox-${this.controlTrait.controlId}` } @@ -180,7 +155,7 @@ export default class Combobox extends WebComponent { .value=${this.displayValue} @keydown=${this.onInputKeyDown} @focus=${this.onInputFocus} - @input=${() => this.selectOnly ? this.updateDisplayValue(this.inputElement?.value ?? '') : this.controlTrait.onInput()} + @input=${() => this.selectOnly ? this.updateDisplayValue(this.controlElement?.value ?? '') : this.controlTrait.onInput()} /> @@ -288,7 +263,7 @@ export default class Combobox extends WebComponent { } private updateDisplayValue (value: unknown) { - this.displayValue = String(value) + this.displayValue = value ? String(value) : '' if (this.open) { const filter = this.displayValue.toLowerCase() @@ -386,7 +361,7 @@ export default class Combobox extends WebComponent { this.hide() this.controlTrait.setValue(option.value) - this.inputElement?.focus({ preventScroll: true }) + this.controlElement?.focus({ preventScroll: true }) this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: { option } })) if (previousValue === this.value) { @@ -455,12 +430,12 @@ export default class Combobox extends WebComponent { } private onAnchorMouseDown (event: MouseEvent) { - if (event.target === this.inputElement) { + if (event.target === this.controlElement) { return } event.preventDefault() - this.inputElement?.focus({ preventScroll: true }) + this.controlElement?.focus({ preventScroll: true }) } private onInputFocus () { @@ -511,7 +486,7 @@ export default class Combobox extends WebComponent { event.preventDefault() event.stopPropagation() this.hide() - this.inputElement?.focus({ preventScroll: true }) + this.controlElement?.focus({ preventScroll: true }) } break case 'Tab': diff --git a/src/components/input/Input.ts b/src/components/input/Input.ts index 9716e5476..9cfcf0b94 100644 --- a/src/components/input/Input.ts +++ b/src/components/input/Input.ts @@ -1,46 +1,18 @@ -import { customElement, WebComponent } from '@/lib/components' +import { customElement, FormControlComponent } from '@/lib/components' import { html } from 'lit' import { property, query } from 'lit/decorators.js' -import FormControlTrait from '@/lib/components/traits/FormControlTrait' import styles from './Input.styles.css' @customElement('solid-ui-input') -export default class Input extends WebComponent { +export default class Input extends FormControlComponent { static styles = styles - static formAssociated = true - - @property({ type: String, reflect: true }) - accessor label = ''; - - @property({ type: String, reflect: true }) - accessor name = ''; - - @property({ type: String }) - accessor value = ''; @property({ type: String, reflect: true }) accessor type = 'text'; - @property({ type: String, reflect: true }) - accessor placeholder = ''; - - @property({ type: Boolean, reflect: true }) - accessor required = false; - @query('input') - private accessor inputElement: HTMLInputElement | null = null; - - private controlTrait: FormControlTrait - - constructor () { - super() - - this.controlTrait = this.addTrait(new FormControlTrait(this, { - getControlElement: () => this.inputElement, - getInternals: () => this.getInternals(), - })) - } + protected accessor controlElement: HTMLInputElement | null = null; protected render () { return html` diff --git a/src/components/login-modal/LoginModal.ts b/src/components/login-modal/LoginModal.ts index 9d90a2567..4c906e818 100644 --- a/src/components/login-modal/LoginModal.ts +++ b/src/components/login-modal/LoginModal.ts @@ -80,7 +80,7 @@ export default class LoginModal extends WebComponent { } private onIssuerInputChange (e: Event) { - this.issuerInputValue = (e.target as Combobox).value + this.issuerInputValue = String((e.target as Combobox).value) } private async onSubmit (e: Event) { diff --git a/src/components/photo-capture-modal/PhotoCaptureModal.styles.css b/src/components/photo-capture-modal/PhotoCaptureModal.styles.css new file mode 100644 index 000000000..1821ca9b2 --- /dev/null +++ b/src/components/photo-capture-modal/PhotoCaptureModal.styles.css @@ -0,0 +1,32 @@ +.viewport { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 200px; + border-radius: 0.5rem; + overflow: hidden; + background: color-mix(in srgb, #ffffff 92%, #000 8%); +} + +.viewport video, +.viewport img { + display: block; + width: 100%; + max-width: 260px; + height: auto; + border-radius: 0.5rem; + margin: 0 auto; + object-fit: cover; +} + +.status { + width: 100%; + text-align: center; + color: var(--photo-capture-muted-text); + font-size: 0.875rem; +} + +.status.error { + color: var(--color-error, #b00020); +} diff --git a/src/components/photo-capture-modal/PhotoCaptureModal.ts b/src/components/photo-capture-modal/PhotoCaptureModal.ts new file mode 100644 index 000000000..f0a79be5a --- /dev/null +++ b/src/components/photo-capture-modal/PhotoCaptureModal.ts @@ -0,0 +1,335 @@ +import { customElement, DialogComponent } from '@/lib/components' +import { html, PropertyValues, nothing } from 'lit' +import { property, state } from 'lit/decorators.js' + +import '@/components/button' +import '@/components/dialog' +import '@/components/dialog-content' +import '@/components/dialog-footer' + +import styles from './PhotoCaptureModal.styles.css' + +@customElement('solid-ui-photo-capture-modal') +export default class PhotoCaptureModal extends DialogComponent { + static styles = styles + + @property({ type: String, reflect: true }) + accessor name = '' + + @property({ type: String, reflect: true }) + accessor heading = 'Take a photo' + + @property({ type: String, reflect: true }) + accessor captureLabel = 'Take Photo' + + @property({ type: String, reflect: true }) + accessor confirmLabel = 'Use Photo' + + @property({ type: String, reflect: true }) + accessor retakeLabel = 'Retake' + + @property({ type: String, attribute: 'cancel-label', reflect: true }) + accessor cancelLabel = 'Cancel' + + @property({ type: String, attribute: 'file-name-prefix', reflect: true }) + accessor fileNamePrefix = '' + + @property({ attribute: false }) + accessor mediaConstraints: MediaStreamConstraints | undefined = undefined + + @property({ type: String, reflect: true }) + accessor constraints = '' + + @property({ type: String, attribute: 'capture-format', reflect: true }) + accessor captureFormat = 'image/png' + + @property({ type: Number, attribute: 'capture-quality' }) + accessor captureQuality: number | undefined = undefined + + @property({ type: Boolean, attribute: 'show-cancel-button', reflect: true }) + accessor showCancelButton = true + + @property({ type: String, attribute: 'facing-mode', reflect: true }) + accessor facingMode = 'user' + + @state() + accessor errorMessage = '' + + @state() + accessor previewUrl = '' + + @state() + accessor startingPreview = false + + @state() + accessor value: File | null = null + + private stream: MediaStream | null = null + + disconnectedCallback () { + this.stopStream() + this.revokePreviewUrl() + super.disconnectedCallback() + } + + protected willUpdate (changed: PropertyValues) { + super.willUpdate(changed) + + if (changed.has('value')) { + const normalizedValue = this.value instanceof File ? this.value : null + + if (normalizedValue !== this.value) { + this.value = normalizedValue + + return + } + + this.syncPreviewFromValue(normalizedValue) + } + } + + protected updated (changed: PropertyValues) { + if ( + !this.value && + !this.stream && + !this.startingPreview && + (changed.has('previewUrl') || changed.has('value')) + ) { + this.queuePreviewStart() + } + + if (this.stream) { + const video = this.shadowRoot?.querySelector('video') + + if (video && video.srcObject !== this.stream) { + video.srcObject = this.stream + } + } + } + + protected render () { + return html` + + + ${this.renderViewport()} + ${this.renderStatus()} + + + ${this.showCancelButton + ? html` this.close()}>${this.cancelLabel}` + : nothing + } + + ${this.value + ? html` + ${this.retakeLabel} + ${this.confirmLabel} + ` + : html` + + ${this.captureLabel} + + ` + } + + + ` + } + + private renderViewport () { + const contents = this.previewUrl + ? html`Captured photo preview` + : html`` + + return html`
${contents}
` + } + + private renderStatus () { + if (this.errorMessage) { + return html`
${this.errorMessage}
` + } + + if (this.startingPreview) { + return html`
Opening camera…
` + } + + if (!this.value) { + return html`
Preview the camera and take a photo when ready.
` + } + + return html`
Review the photo before confirming it.
` + } + + private onRetake () { + this.clearValue() + this.queuePreviewStart() + } + + private onConfirm () { + if (!this.value) { + return + } + + this.close(this.value) + } + + private async onCapture () { + const video = this.shadowRoot?.querySelector('video') + + if (!video) { + return + } + + const width = video.videoWidth || video.clientWidth || 640 + const height = video.videoHeight || video.clientHeight || 480 + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + const context = canvas.getContext('2d') + + if (!context) { + this.errorMessage = 'Unable to capture a photo in this browser' + + return + } + + context.drawImage(video, 0, 0, width, height) + + const blob = await new Promise(resolve => { + canvas.toBlob(resolve, this.captureFormat, this.captureQuality) + }) + + if (!blob) { + this.errorMessage = 'Unable to create an image from the current camera frame' + + return + } + + this.value = this.createFileFromBlob(blob) + this.errorMessage = '' + } + + private createFileFromBlob (blob: Blob): File { + const contentType = blob.type || this.captureFormat + const extension = this.fileExtensionForMimeType(contentType) + const safePrefix = (this.fileNamePrefix || this.name || 'photo').trim() || 'photo' + + return new File([blob], `${safePrefix}-${Date.now()}.${extension}`, { type: contentType }) + } + + private fileExtensionForMimeType (mimeType: string): string { + switch (mimeType) { + case 'image/jpeg': + return 'jpg' + case 'image/webp': + return 'webp' + case 'image/gif': + return 'gif' + default: + return 'png' + } + } + + private queuePreviewStart () { + this.startPreview().catch(() => undefined) + } + + private stopStream () { + if (!this.stream) { + return + } + + this.stream.getTracks().forEach(track => track.stop()) + this.stream = null + + const video = this.shadowRoot?.querySelector('video') + + if (video) { + video.srcObject = null + } + } + + private syncPreviewFromValue (file: File | null) { + this.revokePreviewUrl() + + if (!file) { + return + } + + this.stopStream() + this.previewUrl = URL.createObjectURL(file) + } + + private clearValue () { + this.value = null + this.errorMessage = '' + } + + private revokePreviewUrl () { + if (this.previewUrl) { + URL.revokeObjectURL(this.previewUrl) + } + + this.previewUrl = '' + } + + private async startPreview () { + if (this.value || this.startingPreview) { + return + } + + if (!navigator.mediaDevices?.getUserMedia) { + this.errorMessage = 'Camera access is not available in this browser' + + return + } + + this.startingPreview = true + this.errorMessage = '' + + try { + const stream = await navigator.mediaDevices.getUserMedia(this.resolveMediaConstraints()) + + this.stream = stream + this.requestUpdate() + + await this.updateComplete + + const video = this.shadowRoot?.querySelector('video') + + if (video) { + video.srcObject = stream + + await video.play?.().catch(() => undefined) + } + } catch (error) { + this.errorMessage = (error as Error)?.message || 'Unable to start the camera preview' + } finally { + this.startingPreview = false + } + } + + private resolveMediaConstraints (): MediaStreamConstraints { + if (this.mediaConstraints) { + return this.mediaConstraints + } + + if (this.constraints) { + try { + return JSON.parse(this.constraints) as MediaStreamConstraints + } catch (error) { + throw new Error(`Invalid constraints JSON: ${(error as Error).message}`) + } + } + + return { + video: this.facingMode + ? { facingMode: { ideal: this.facingMode } } + : true + } + } +} diff --git a/src/components/photo-capture-modal/index.ts b/src/components/photo-capture-modal/index.ts new file mode 100644 index 000000000..7ca6fbb1d --- /dev/null +++ b/src/components/photo-capture-modal/index.ts @@ -0,0 +1,4 @@ +import PhotoCaptureModal from './PhotoCaptureModal' + +export { PhotoCaptureModal } +export default PhotoCaptureModal diff --git a/src/components/photo-capture/PhotoCapture.stories.ts b/src/components/photo-capture/PhotoCapture.stories.ts new file mode 100644 index 000000000..137994853 --- /dev/null +++ b/src/components/photo-capture/PhotoCapture.stories.ts @@ -0,0 +1,71 @@ +import { html } from 'lit' + +import './docs/photo-capture-sandbox' + +const meta = { + title: 'Advanced/PhotoCapture', + parameters: { + docs: { + source: { + language: 'ts', + code: ` + import { customElement, WebComponent } from 'solid-ui' + import { html } from 'lit' + import { state, query } from 'lit/decorators.js' + + import PhotoCapture from 'solid-ui/components/photo-capture' + + import styles from './PhotoCaptureSandbox.styles.css' + + @customElement('my-photo-capture-sandbox') + export default class PhotoCaptureSandbox extends WebComponent { + static styles = styles + + @state() + private accessor imageUrl: string | null = null + + @query('solid-ui-photo-capture') + private accessor photoCapture: PhotoCapture | null = null + + protected render () { + const photoCapture = html\` + + \` + + if (this.imageUrl) { + return html\` + \${photoCapture} + +

The photo you captured:

+ Captured photo + \` + } + + return html\` +

Use the following button to capture a photo:

+ + \${photoCapture} + \` + } + + private onInput () { + const file = this.photoCapture?.value ?? null + + this.imageUrl = file ? URL.createObjectURL(file) : null + } + } + ` + } + } + } +} as const + +export const Primary = { + render: () => html``, +} + +export default meta diff --git a/src/components/photo-capture/PhotoCapture.ts b/src/components/photo-capture/PhotoCapture.ts new file mode 100644 index 000000000..0a951d668 --- /dev/null +++ b/src/components/photo-capture/PhotoCapture.ts @@ -0,0 +1,79 @@ +import { html } from 'lit' +import { property } from 'lit/decorators.js' +import { customElement, FormControlComponent } from '@/lib/components' +import { showDialog } from '@/lib/dialogs' + +import PhotoCaptureModal from '@/components/photo-capture-modal' + +@customElement('solid-ui-photo-capture') +export default class PhotoCapture extends FormControlComponent { + @property({ type: String, reflect: true }) + accessor label = 'Take Photo' + + @property({ type: String, reflect: true }) + accessor heading = 'Take a photo' + + @property({ type: String, attribute: 'capture-label', reflect: true }) + accessor captureLabel = 'Take Photo' + + @property({ type: String, attribute: 'confirm-label', reflect: true }) + accessor confirmLabel = 'Use Photo' + + @property({ type: String, attribute: 'retake-label', reflect: true }) + accessor retakeLabel = 'Retake' + + @property({ type: String, attribute: 'cancel-label', reflect: true }) + accessor cancelLabel = 'Cancel' + + @property({ type: String, attribute: 'facing-mode', reflect: true }) + accessor facingMode = 'user' + + @property({ type: String, reflect: true }) + accessor constraints = '' + + @property({ type: String, attribute: 'capture-format', reflect: true }) + accessor captureFormat = 'image/png' + + @property({ type: Number, attribute: 'capture-quality' }) + accessor captureQuality: number | undefined = undefined + + @property({ type: Boolean, attribute: 'show-cancel-button', reflect: true }) + accessor showCancelButton = true + + @property({ type: String, attribute: 'file-name-prefix', reflect: true }) + accessor fileNamePrefix = '' + + @property({ attribute: false }) + accessor mediaConstraints: MediaStreamConstraints | undefined = undefined + + protected controlElement = null + + protected render () { + return html` + + ${this.label} + + ` + } + + private onClick () { + showDialog(PhotoCaptureModal, { + props: { + name: this.name, + heading: this.heading, + captureLabel: this.captureLabel, + confirmLabel: this.confirmLabel, + retakeLabel: this.retakeLabel, + cancelLabel: this.cancelLabel, + fileNamePrefix: this.fileNamePrefix, + mediaConstraints: this.mediaConstraints, + constraints: this.constraints, + captureFormat: this.captureFormat, + captureQuality: this.captureQuality, + showCancelButton: this.showCancelButton, + facingMode: this.facingMode, + }, + onClose: value => this.controlTrait.setValue(value ?? null) + }) + } +} diff --git a/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.styles.css b/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.styles.css new file mode 100644 index 000000000..ce8b96eac --- /dev/null +++ b/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.styles.css @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + gap: 6px; +} diff --git a/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.ts b/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.ts new file mode 100644 index 000000000..f4118cd61 --- /dev/null +++ b/src/components/photo-capture/docs/photo-capture-sandbox/PhotoCaptureSandbox.ts @@ -0,0 +1,50 @@ +import { customElement, WebComponent } from '@/lib/components' +import { html } from 'lit' +import { state, query } from 'lit/decorators.js' +import type PhotoCapture from '@/components/photo-capture' + +import '@/components/photo-capture' + +import styles from './PhotoCaptureSandbox.styles.css' + +@customElement('solid-ui-photo-capture-sandbox') +export default class PhotoCaptureSandbox extends WebComponent { + static styles = styles + + @state() + private accessor imageUrl: string | null = null + + @query('solid-ui-photo-capture') + private accessor photoCapture: PhotoCapture | null = null + + protected render () { + const photoCapture = html` + + ` + + if (this.imageUrl) { + return html` + ${photoCapture} + +

The photo you captured:

+ Captured photo + ` + } + + return html` +

Use the following button to capture a photo:

+ + ${photoCapture} + ` + } + + private onInput () { + const file = this.photoCapture?.value ?? null + + this.imageUrl = file ? URL.createObjectURL(file) : null + } +} diff --git a/src/components/photo-capture/docs/photo-capture-sandbox/index.ts b/src/components/photo-capture/docs/photo-capture-sandbox/index.ts new file mode 100644 index 000000000..4b12479b2 --- /dev/null +++ b/src/components/photo-capture/docs/photo-capture-sandbox/index.ts @@ -0,0 +1,4 @@ +import PhotoCaptureSandbox from './PhotoCaptureSandbox' + +export { PhotoCaptureSandbox } +export default PhotoCaptureSandbox diff --git a/src/components/photo-capture/index.ts b/src/components/photo-capture/index.ts index f4e3ce427..0cc401649 100644 --- a/src/components/photo-capture/index.ts +++ b/src/components/photo-capture/index.ts @@ -1 +1,5 @@ -export * from '../../v2/components/media/photoCapture' +import PhotoCapture from './PhotoCapture' + +export * from './PhotoCapture' +export { PhotoCapture } +export default PhotoCapture diff --git a/src/components/select/Select.ts b/src/components/select/Select.ts index 4c5d8b6a4..57806b4c8 100644 --- a/src/components/select/Select.ts +++ b/src/components/select/Select.ts @@ -1,8 +1,7 @@ -import { customElement, WebComponent } from '@/lib/components' +import { customElement, FormControlComponent } from '@/lib/components' import { html, nothing } from 'lit' import { property, query, state } from 'lit/decorators.js' import { isEmptyValue } from '@/lib/values' -import FormControlTrait from '@/lib/components/traits/FormControlTrait' import type SelectOption from '@/components/select-option/SelectOption' import '~icons/lucide/chevron-down' @@ -17,21 +16,8 @@ export type SelectOptionData = { export type SelectChangeEvent = CustomEvent<{ option: SelectOptionData }> @customElement('solid-ui-select') -export default class Select extends WebComponent { +export default class Select extends FormControlComponent { static styles = styles - static formAssociated = true - - @property({ type: String, reflect: true }) - accessor label = ''; - - @property({ type: String, reflect: true }) - accessor name = ''; - - @property({ type: String }) - accessor value = ''; - - @property({ type: Boolean, reflect: true }) - accessor required = false; @property({ type: Array }) set options (value: SelectOptionData[] | null) { @@ -54,22 +40,11 @@ export default class Select extends WebComponent { } @query('select') - accessor inputElement: HTMLSelectElement | null = null; + protected accessor controlElement: HTMLSelectElement | null = null; @state() private accessor _options: SelectOptionData[] | null = null - private controlTrait: FormControlTrait - - constructor () { - super() - - this.controlTrait = this.addTrait(new FormControlTrait(this, { - getControlElement: () => this.inputElement, - getInternals: () => this.getInternals(), - })) - } - protected render () { const defaultOption = this.options.some(option => isEmptyValue(option.value)) ? nothing @@ -102,7 +77,7 @@ export default class Select extends WebComponent { } private onChange () { - const value = this.inputElement?.value + const value = this.controlElement?.value ?? null const option = this.options.find((option) => option.value === value) this.controlTrait.setValue(value) diff --git a/src/lib/components/form-control-component/FormControlComponent.ts b/src/lib/components/form-control-component/FormControlComponent.ts new file mode 100644 index 000000000..9a5e0b4da --- /dev/null +++ b/src/lib/components/form-control-component/FormControlComponent.ts @@ -0,0 +1,40 @@ +import { WebComponent } from '@/lib/components/web-component' +import { property } from 'lit/decorators.js' +import FormControlTrait, { FormControlValue } from '@/lib/components/traits/FormControlTrait' + +export default abstract class FormControlComponent extends WebComponent { + static formAssociated = true + + @property({ type: String, reflect: true }) + accessor label = '' + + @property({ type: String, reflect: true }) + accessor name = '' + + @property() + accessor value: T | null = null + + @property({ type: String, reflect: true }) + accessor placeholder = '' + + @property({ type: Boolean, reflect: true }) + accessor required = false + + @property({ type: Boolean, reflect: true }) + accessor disabled = false + + protected controlTrait: FormControlTrait + + protected abstract controlElement: HTMLInputElement | HTMLSelectElement | null + + constructor () { + super() + + this.controlTrait = this.addTrait( + new FormControlTrait(this, { + getControlElement: () => this.controlElement, + getInternals: () => this.getInternals(), + }) + ) + } +} diff --git a/src/lib/components/form-control-component/index.ts b/src/lib/components/form-control-component/index.ts new file mode 100644 index 000000000..fb13ebd8d --- /dev/null +++ b/src/lib/components/form-control-component/index.ts @@ -0,0 +1,4 @@ +import FormControlComponent from './FormControlComponent' + +export { FormControlComponent } +export default FormControlComponent diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index 2ec105c37..2e8368dce 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -1,5 +1,6 @@ export * from './decorators' export * from './dialog-component' +export * from './form-control-component' export * from './ids' export * from './traits' export * from './web-component' diff --git a/src/lib/components/traits/FormControlTrait.ts b/src/lib/components/traits/FormControlTrait.ts index 0c61694c5..c7dc60883 100644 --- a/src/lib/components/traits/FormControlTrait.ts +++ b/src/lib/components/traits/FormControlTrait.ts @@ -5,11 +5,14 @@ import { generateId } from '@/lib/components' import type { WebComponentTrait } from './WebComponentTrait' +export type FormControlValue = string | File | FormData | null + export type FormControlTraitTarget = WebComponent & { name: string; label: string; required: boolean; - value: unknown; + disabled: boolean; + value: FormControlValue; } export interface FormControlTraitConfig { @@ -31,7 +34,7 @@ export default class FormControlTrait implements WebComponentTrait { } firstUpdated () { - this.config.getInternals().setFormValue(String(this.target.value ?? '')) + this.config.getInternals().setFormValue(this.target.value ?? '') this.updateValidity() } @@ -54,17 +57,17 @@ export default class FormControlTrait implements WebComponentTrait { } onInput () { - this.setValue(this.config.getControlElement()?.value) + this.setValue(this.config.getControlElement()?.value ?? null) } onSubmit () { this.config.getInternals().form?.requestSubmit() } - setValue (value: unknown) { + setValue (value: FormControlValue) { this.target.value = value - this.config.getInternals().setFormValue(String(this.target.value ?? '')) + this.config.getInternals().setFormValue(this.target.value ?? '') this.target.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })) } diff --git a/src/v2/components/media/photoCapture/PhotoCapture.test.ts b/src/v2/components/media/photoCapture/PhotoCapture.test.ts deleted file mode 100644 index a607e6acf..000000000 --- a/src/v2/components/media/photoCapture/PhotoCapture.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PhotoCapture } from './PhotoCapture' -import './index' - -describe('SolidUIPhotoCapture', () => { - const stopTrack = vi.fn() - const getUserMedia: any = vi.fn() - - beforeEach(() => { - document.body.innerHTML = '' - stopTrack.mockReset() - getUserMedia.mockReset() - getUserMedia.mockResolvedValue({ - getTracks: () => [{ stop: stopTrack }], - getVideoTracks: () => [{ stop: stopTrack }] - }) - - Object.defineProperty(navigator, 'mediaDevices', { - configurable: true, - value: { getUserMedia } - }) - - Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { - configurable: true, - get () { - return (this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject ?? null - }, - set (value) { - ;(this as HTMLMediaElement & { __srcObject?: MediaStream | null }).__srcObject = value as MediaStream | null - } - }) - - Object.defineProperty(HTMLMediaElement.prototype, 'play', { - configurable: true, - value: vi.fn(() => Promise.resolve(undefined)) - }) - - Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { - configurable: true, - value: vi.fn() - }) - - Object.defineProperty(HTMLDialogElement.prototype, 'close', { - configurable: true, - value: vi.fn() - }) - - Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { - configurable: true, - value: vi.fn(() => ({ drawImage: vi.fn() })) - }) - - Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { - configurable: true, - value: vi.fn((callback: BlobCallback, type?: string) => { - callback(new Blob(['photo'], { type: type || 'image/png' })) - }) - }) - - Object.defineProperty(URL, 'createObjectURL', { - configurable: true, - value: vi.fn(() => 'blob:test-photo') - }) - - Object.defineProperty(URL, 'revokeObjectURL', { - configurable: true, - value: vi.fn() - }) - }) - - it('is defined as a custom element', () => { - expect(customElements.get('solid-ui-photo-capture')).toBe(PhotoCapture) - }) - - it('is closed by default and only starts the inline preview when opened', async () => { - const photoCapture = new PhotoCapture() - - document.body.appendChild(photoCapture) - await photoCapture.updateComplete - - expect(photoCapture.open).toBe(false) - expect(getUserMedia).not.toHaveBeenCalled() - - photoCapture.open = true - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - expect(getUserMedia).toHaveBeenCalledWith({ - video: { - facingMode: { ideal: 'user' } - } - }) - }) - - it('accepts dialog presentation and custom constraints JSON', async () => { - const photoCapture = new PhotoCapture() - photoCapture.presentation = 'dialog' - photoCapture.constraints = JSON.stringify({ video: true, audio: false }) - - document.body.appendChild(photoCapture) - await photoCapture.updateComplete - - expect(photoCapture.open).toBe(false) - expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled() - - const trigger = photoCapture.shadowRoot?.querySelector('button.trigger-button') as HTMLButtonElement - trigger.click() - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - expect(photoCapture.open).toBe(true) - expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled() - expect(getUserMedia).toHaveBeenCalledWith({ video: true, audio: false }) - }) - - it('dispatches a photo-captured event with the confirmed blob', async () => { - const photoCapture = new PhotoCapture() - const captured = vi.fn() - const changed = vi.fn() - photoCapture.open = true - - photoCapture.addEventListener('photo-captured', (event: Event) => { - captured((event as CustomEvent).detail) - }) - photoCapture.addEventListener('change', (event: Event) => { - changed((event as CustomEvent).detail) - }) - - document.body.appendChild(photoCapture) - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement - Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) - Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) - - await (photoCapture as any)._captureSnapshot() - await photoCapture.updateComplete - - const confirmButton = photoCapture.shadowRoot?.querySelector('[part="confirm-button"]') as HTMLButtonElement - confirmButton.click() - - expect(captured).toHaveBeenCalledWith({ - file: expect.any(File), - blob: expect.any(Blob), - objectUrl: 'blob:test-photo', - contentType: 'image/png' - }) - expect(photoCapture.value).toBeInstanceOf(File) - expect(changed).toHaveBeenCalledWith({ value: photoCapture.value }) - }) - - it('can participate in a form-like submission while still exposing a value property', async () => { - const form = document.createElement('form') - const photoCapture = new PhotoCapture() - photoCapture.open = true - photoCapture.name = 'avatar' - form.appendChild(photoCapture) - document.body.appendChild(form) - - await photoCapture.updateComplete - await Promise.resolve() - await photoCapture.updateComplete - - const video = photoCapture.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement - Object.defineProperty(video, 'videoWidth', { configurable: true, value: 320 }) - Object.defineProperty(video, 'videoHeight', { configurable: true, value: 240 }) - - await (photoCapture as any)._captureSnapshot() - await photoCapture.updateComplete - ;(photoCapture as any)._confirmPhoto() - - expect(photoCapture.value).toBeInstanceOf(File) - - const formData = new FormData() - const formDataEvent = new Event('formdata') as Event & { formData: FormData } - formDataEvent.formData = formData - form.dispatchEvent(formDataEvent) - - const submitted = formData.get('avatar') - expect(submitted).toBeInstanceOf(File) - expect((submitted as File).name).toContain('avatar-') - }) -}) diff --git a/src/v2/components/media/photoCapture/PhotoCapture.ts b/src/v2/components/media/photoCapture/PhotoCapture.ts deleted file mode 100644 index 87913166c..000000000 --- a/src/v2/components/media/photoCapture/PhotoCapture.ts +++ /dev/null @@ -1,825 +0,0 @@ -import { LitElement, css, html, nothing, type PropertyValues } from 'lit' -/* The original code was written by Sir Tim Berners-Lee. It was made into a -web component by AI Model GPT-5.4 -Prompt: Take the code from src/media/media-capture.ts and make it a -web component. Make it work in forms as well as not. Make it -configurable and follow LoginButton. */ -export interface PhotoCapturedDetail { - file: File - blob: Blob - objectUrl: string - contentType: string -} - -export interface PhotoCaptureErrorDetail { - error: unknown - message: string -} - -export interface PhotoCaptureOpenChangeDetail { - open: boolean -} - -export interface PhotoCaptureValueDetail { - value: File | null -} - -type PresentationMode = 'inline' | 'dialog' -type ThemeMode = 'light' | 'dark' - -const DEFAULT_CAPTURE_FORMAT = 'image/png' - -export class PhotoCapture extends LitElement { - static formAssociated = true - - static properties = { - label: { type: String, reflect: true }, - heading: { type: String, reflect: true }, - captureLabel: { type: String, attribute: 'capture-label', reflect: true }, - confirmLabel: { type: String, attribute: 'confirm-label', reflect: true }, - retakeLabel: { type: String, attribute: 'retake-label', reflect: true }, - cancelLabel: { type: String, attribute: 'cancel-label', reflect: true }, - presentation: { type: String, reflect: true }, - theme: { type: String, reflect: true }, - facingMode: { type: String, attribute: 'facing-mode', reflect: true }, - constraints: { type: String, reflect: true }, - captureFormat: { type: String, attribute: 'capture-format', reflect: true }, - captureQuality: { type: Number, attribute: 'capture-quality' }, - open: { type: Boolean, reflect: true }, - disabled: { type: Boolean, reflect: true }, - name: { type: String, reflect: true }, - required: { type: Boolean, reflect: true }, - showTrigger: { type: Boolean, attribute: 'show-trigger', reflect: true }, - showCancelButton: { type: Boolean, attribute: 'show-cancel-button', reflect: true }, - autoCloseOnCapture: { type: Boolean, attribute: 'auto-close-on-capture' }, - fileNamePrefix: { type: String, attribute: 'file-name-prefix', reflect: true }, - value: { attribute: false }, - mediaConstraints: { attribute: false }, - _errorMessage: { state: true }, - _previewUrl: { state: true }, - _startingPreview: { state: true } - } - - static styles = css` - :host { - display: block; - --photo-capture-trigger-background: var(--lavender-900, #7c4cff); - --photo-capture-trigger-text: var(--color-header-text, #ffffff); - --photo-capture-surface: var(--color-background, #ffffff); - --photo-capture-text: var(--gray-900, #101828); - --photo-capture-muted-text: var(--gray-600, #4a5565); - --photo-capture-border: var(--gray-200, #e5e7eb); - --photo-capture-hover: var(--gray-100, #f3f4f6); - --photo-capture-shadow: var(--box-shadow-sm, 0 1px 4px rgba(0, 0, 0, 0.12)); - --photo-capture-overlay: rgba(0, 0, 0, 0.6); - --photo-capture-frame-max-width: 260px; - --photo-capture-radius: 8px; - --photo-capture-button-radius: var(--border-radius-base, 0.3125rem); - --photo-capture-gap: var(--spacing-2xs, 0.625rem); - color: var(--photo-capture-text); - box-sizing: border-box; - } - - :host([theme='dark']) { - --photo-capture-surface: var(--gray-900, #111827); - --photo-capture-text: var(--white, #ffffff); - --photo-capture-muted-text: var(--gray-300, #d1d5dc); - --photo-capture-border: var(--gray-700, #364153); - --photo-capture-hover: rgba(255, 255, 255, 0.08); - --photo-capture-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); - } - - *, *::before, *::after { - box-sizing: border-box; - } - - .trigger-button, - .action-button, - .cancel-button, - .close-button { - font: inherit; - cursor: pointer; - } - - .trigger-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 35px; - padding: 0.5rem 0.9rem; - border: none; - border-radius: var(--photo-capture-button-radius); - background: var(--photo-capture-trigger-background); - color: var(--photo-capture-trigger-text); - transition: transform 0.2s ease; - } - - .trigger-button:active { - transform: translateY(1px); - } - - .trigger-button:disabled, - .action-button:disabled, - .cancel-button:disabled { - opacity: 0.55; - cursor: not-allowed; - } - - .inline-root[hidden] { - display: none; - } - - .dialog { - border: none; - padding: 0; - background: transparent; - outline: none; - overflow: visible; - max-width: none; - max-height: none; - } - - .dialog::backdrop { - background: var(--photo-capture-overlay); - } - - .panel { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - gap: var(--photo-capture-gap); - width: min(100%, 340px); - padding: 1rem; - border: 1px solid var(--photo-capture-border); - border-radius: var(--photo-capture-radius); - background: var(--photo-capture-surface); - color: var(--photo-capture-text); - box-shadow: var(--photo-capture-shadow); - } - - .panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - width: 100%; - } - - .panel-heading { - margin: 0; - font-size: 1rem; - font-weight: 700; - line-height: 1.4; - } - - .close-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; - padding: 0; - border: none; - border-radius: 999px; - background: transparent; - color: var(--photo-capture-muted-text); - font-size: 1.125rem; - line-height: 1; - } - - .close-button:hover, - .close-button:focus-visible, - .action-button:hover, - .action-button:focus-visible, - .cancel-button:hover, - .cancel-button:focus-visible { - background: var(--photo-capture-hover); - } - - .viewport { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - min-height: 200px; - border-radius: 0.5rem; - overflow: hidden; - background: color-mix(in srgb, var(--photo-capture-surface) 92%, #000 8%); - } - - .viewport video, - .viewport img { - display: block; - width: 100%; - max-width: var(--photo-capture-frame-max-width); - height: auto; - border-radius: 0.5rem; - margin: 0 auto; - object-fit: cover; - } - - .status { - width: 100%; - text-align: center; - color: var(--photo-capture-muted-text); - font-size: 0.875rem; - } - - .status.error { - color: var(--color-error, #b00020); - } - - .actions { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: var(--photo-capture-gap); - width: 100%; - } - - .action-button, - .cancel-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 2.25rem; - padding: 0.45rem 0.85rem; - border-radius: var(--photo-capture-button-radius); - border: 1px solid var(--photo-capture-border); - background: var(--photo-capture-surface); - color: var(--photo-capture-text); - font-size: var(--font-size-xxs, 0.75rem); - font-weight: var(--font-weight-xbold, 700); - line-height: 1.5; - } - - .action-button--primary { - background: var(--photo-capture-trigger-background); - color: var(--photo-capture-trigger-text); - border-color: transparent; - } - ` - - declare label: string - declare heading: string - declare captureLabel: string - declare confirmLabel: string - declare retakeLabel: string - declare cancelLabel: string - declare presentation: PresentationMode - declare theme: ThemeMode - declare facingMode: string - declare constraints: string - declare captureFormat: string - declare captureQuality?: number - declare open: boolean - declare disabled: boolean - declare name: string - declare required: boolean - declare showTrigger: boolean - declare showCancelButton: boolean - declare autoCloseOnCapture: boolean - declare fileNamePrefix: string - declare mediaConstraints?: MediaStreamConstraints - declare _errorMessage: string - declare _previewUrl: string - declare _startingPreview: boolean - - private _value: File | null = null - private _stream: MediaStream | null = null - private readonly _internals: ElementInternals | null - private _associatedForm: HTMLFormElement | null = null - private readonly _handleFormData = (event: Event) => { - const formData = (event as Event & { formData?: FormData }).formData - if (!formData || !this.name || !this.value || this.disabled) return - formData.append(this.name, this.value, this.value.name) - } - - private readonly _handleFormReset = () => { - this._clearValue({ emitEvents: false }) - if (this.open) { - this._queuePreviewStart() - } - } - - private get _supportsFormInternals (): boolean { - return !!this._internals && typeof this._internals.setFormValue === 'function' - } - - constructor () { - super() - this.label = 'Take Photo' - this.heading = 'Take a photo' - this.captureLabel = 'Take Photo' - this.confirmLabel = 'Use Photo' - this.retakeLabel = 'Retake' - this.cancelLabel = 'Cancel' - this.presentation = 'inline' - this.theme = 'light' - this.facingMode = 'user' - this.constraints = '' - this.captureFormat = DEFAULT_CAPTURE_FORMAT - this.captureQuality = undefined - this.open = false - this.disabled = false - this.name = '' - this.required = false - this.showTrigger = false - this.showCancelButton = true - this.autoCloseOnCapture = false - this.fileNamePrefix = '' - this.mediaConstraints = undefined - this._errorMessage = '' - this._previewUrl = '' - this._startingPreview = false - this._internals = typeof this.attachInternals === 'function' ? this.attachInternals() : null - } - - get value (): File | null { - return this._value - } - - set value (nextValue: File | null) { - const normalizedValue = nextValue instanceof File ? nextValue : null - const previousValue = this._value - if (previousValue === normalizedValue) return - - this._value = normalizedValue - this._syncPreviewFromValue(normalizedValue) - this._syncFormValue() - this._syncValidity() - this.requestUpdate('value', previousValue) - } - - get form (): HTMLFormElement | null { - return (this._supportsFormInternals ? this._internals?.form : null) ?? this._associatedForm - } - - get validationMessage (): string { - return (typeof this._internals?.validationMessage === 'string' ? this._internals.validationMessage : '') || (this.required && !this.value ? 'Please capture a photo.' : '') - } - - get willValidate (): boolean { - return typeof this._internals?.willValidate === 'boolean' ? this._internals.willValidate : !this.disabled - } - - checkValidity (): boolean { - if (this._internals && typeof this._internals.checkValidity === 'function') { - return this._internals.checkValidity() - } - return !(this.required && !this.value) - } - - reportValidity (): boolean { - if (this._internals && typeof this._internals.reportValidity === 'function') { - return this._internals.reportValidity() - } - return this.checkValidity() - } - - connectedCallback () { - super.connectedCallback() - this._syncAssociatedForm() - this._syncFormValue() - this._syncValidity() - } - - disconnectedCallback () { - this._syncAssociatedForm(null) - this._stopStream() - this._revokePreviewUrl() - super.disconnectedCallback() - } - - formResetCallback () { - this._handleFormReset() - } - - formDisabledCallback (disabled: boolean) { - this.disabled = disabled - } - - protected updated (changed: PropertyValues) { - this._syncAssociatedForm() - - if (this.presentation === 'dialog') { - const dialog = this.shadowRoot?.querySelector('dialog') as HTMLDialogElement | null - if (dialog) { - if (this.open && !dialog.open) { - dialog.showModal() - } else if (!this.open && dialog.open) { - dialog.close() - } - } - } - - if (changed.has('open') && !this.open) { - this._stopStream() - } - - if ( - this.open && - !this.value && - !this._stream && - !this._startingPreview && - (changed.has('open') || changed.has('presentation') || changed.has('_previewUrl') || changed.has('value')) - ) { - this._queuePreviewStart() - } - - if (changed.has('name') || changed.has('disabled') || changed.has('value')) { - this._syncFormValue() - } - - if (changed.has('required') || changed.has('disabled') || changed.has('value')) { - this._syncValidity() - } - - if (this._stream) { - const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null - if (video && video.srcObject !== this._stream) { - video.srcObject = this._stream - } - } - } - - private _setOpen (open: boolean) { - if (this.open === open) return - this.open = open - this.dispatchEvent(new CustomEvent('open-change', { - detail: { open }, - bubbles: true, - composed: true - })) - } - - private _emitError (error: unknown, message = 'Unable to access the camera') { - this._errorMessage = message - this.dispatchEvent(new CustomEvent('error', { - detail: { error, message }, - bubbles: true, - composed: true - })) - } - - private _syncAssociatedForm (nextForm = this.closest('form') as HTMLFormElement | null) { - if (this._associatedForm === nextForm) return - - if (this._associatedForm) { - this._associatedForm.removeEventListener('formdata', this._handleFormData) - this._associatedForm.removeEventListener('reset', this._handleFormReset) - } - - this._associatedForm = nextForm - - if (this._associatedForm && !this._supportsFormInternals) { - this._associatedForm.addEventListener('formdata', this._handleFormData) - this._associatedForm.addEventListener('reset', this._handleFormReset) - } - } - - private _syncFormValue () { - if (!this._supportsFormInternals) return - const internals = this._internals - if (!internals) return - if (this.disabled || !this.name || !this.value) { - internals.setFormValue(null) - return - } - internals.setFormValue(this.value) - } - - private _syncValidity () { - if (!this._internals || !this._supportsFormInternals || typeof this._internals.setValidity !== 'function') return - if (this.disabled || !this.required || this.value) { - this._internals.setValidity({}) - return - } - this._internals.setValidity({ valueMissing: true }, 'Please capture a photo.') - } - - private _syncPreviewFromValue (file: File | null) { - this._revokePreviewUrl() - if (!file) return - this._stopStream() - this._previewUrl = URL.createObjectURL(file) - } - - private _clearValue (options: { emitEvents: boolean }) { - this.value = null - this._errorMessage = '' - if (options.emitEvents) { - this._dispatchValueEvents() - } - } - - private _dispatchValueEvents () { - const detail = { value: this.value } - this.dispatchEvent(new CustomEvent('input', { - detail, - bubbles: true, - composed: true - })) - this.dispatchEvent(new CustomEvent('change', { - detail, - bubbles: true, - composed: true - })) - } - - private _fileExtensionForMimeType (mimeType: string): string { - switch (mimeType) { - case 'image/jpeg': - return 'jpg' - case 'image/webp': - return 'webp' - case 'image/gif': - return 'gif' - default: - return 'png' - } - } - - private _createFileFromBlob (blob: Blob): File { - const contentType = blob.type || this.captureFormat || DEFAULT_CAPTURE_FORMAT - const extension = this._fileExtensionForMimeType(contentType) - const safePrefix = (this.fileNamePrefix || this.name || 'photo').trim() || 'photo' - return new File([blob], `${safePrefix}-${Date.now()}.${extension}`, { type: contentType }) - } - - private _queuePreviewStart () { - this._startPreview().catch(() => undefined) - } - - private _resolveMediaConstraints (): MediaStreamConstraints { - if (this.mediaConstraints) { - return this.mediaConstraints - } - if (this.constraints) { - try { - return JSON.parse(this.constraints) as MediaStreamConstraints - } catch (error) { - throw new Error(`Invalid constraints JSON: ${(error as Error).message}`) - } - } - - return { - video: this.facingMode - ? { facingMode: { ideal: this.facingMode } } - : true - } - } - - private async _startPreview () { - if (!this.open || this.value || this._startingPreview) return - if (!navigator.mediaDevices?.getUserMedia) { - this._emitError(new Error('navigator.mediaDevices.getUserMedia not available'), 'Camera access is not available in this browser') - return - } - - this._startingPreview = true - this._errorMessage = '' - - try { - const stream = await navigator.mediaDevices.getUserMedia(this._resolveMediaConstraints()) - if (!this.open) { - stream.getTracks().forEach(track => track.stop()) - return - } - this._stream = stream - this.requestUpdate() - await this.updateComplete - const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null - if (video) { - video.srcObject = stream - await video.play?.().catch(() => undefined) - } - } catch (error) { - this._emitError(error, (error as Error)?.message || 'Unable to start the camera preview') - } finally { - this._startingPreview = false - } - } - - private _stopStream () { - if (!this._stream) return - this._stream.getTracks().forEach(track => track.stop()) - this._stream = null - const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null - if (video) { - video.srcObject = null - } - } - - private _revokePreviewUrl () { - if (this._previewUrl) { - URL.revokeObjectURL(this._previewUrl) - } - this._previewUrl = '' - } - - private async _captureSnapshot () { - const video = this.shadowRoot?.querySelector('video.capture-preview') as HTMLVideoElement | null - if (!video) return - - const width = video.videoWidth || video.clientWidth || 640 - const height = video.videoHeight || video.clientHeight || 480 - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = height - - const context = canvas.getContext('2d') - if (!context) { - this._emitError(new Error('Canvas 2D context unavailable'), 'Unable to capture a photo in this browser') - return - } - - context.drawImage(video, 0, 0, width, height) - - const blob = await new Promise(resolve => { - canvas.toBlob(resolve, this.captureFormat || DEFAULT_CAPTURE_FORMAT, this.captureQuality) - }) - - if (!blob) { - this._emitError(new Error('Camera snapshot failed'), 'Unable to create an image from the current camera frame') - return - } - - this.value = this._createFileFromBlob(blob) - this._errorMessage = '' - } - - private async _retakePhoto () { - this._clearValue({ emitEvents: true }) - await this._startPreview() - } - - private _confirmPhoto () { - if (!this.value || !this._previewUrl) return - - this._dispatchValueEvents() - - this.dispatchEvent(new CustomEvent('photo-captured', { - detail: { - file: this.value, - blob: this.value, - objectUrl: this._previewUrl, - contentType: this.value.type || this.captureFormat || DEFAULT_CAPTURE_FORMAT - }, - bubbles: true, - composed: true - })) - - if (this.autoCloseOnCapture) { - this._setOpen(false) - } - } - - private _handleCancel () { - this._stopStream() - this._clearValue({ emitEvents: false }) - this._setOpen(false) - this.dispatchEvent(new CustomEvent('cancel', { - bubbles: true, - composed: true - })) - } - - private _openCapture () { - if (this.disabled) return - this._setOpen(true) - } - - private _renderViewport () { - if (this._previewUrl) { - return html`Captured photo preview` - } - - return html`` - } - - private _renderStatus () { - if (this._errorMessage) { - return html`
${this._errorMessage}
` - } - - if (this._startingPreview) { - return html`
Opening camera…
` - } - - if (!this.value) { - return html`
Preview the camera and take a photo when ready.
` - } - - return html`
Review the photo before confirming it.
` - } - - private _renderActions () { - return html` -
- ${this.showCancelButton - ? html` - - ` - : nothing} - - ${this.value - ? html` - - - ` - : html` - - `} -
- ` - } - - private _renderPanel () { - return html` -
-
-

${this.heading}

- ${this.showCancelButton - ? html` - - ` - : nothing} -
-
${this._renderViewport()}
- ${this._renderStatus()} - ${this._renderActions()} -
- ` - } - - render () { - const trigger = this.showTrigger || this.presentation === 'dialog' - - return html` - ${trigger - ? html` - - ` - : nothing} - - ${this.presentation === 'dialog' - ? html` - - ${this.open ? this._renderPanel() : nothing} - - ` - : html` -
- ${this.open ? this._renderPanel() : nothing} -
- `} - ` - } -} diff --git a/src/v2/components/media/photoCapture/README.md b/src/v2/components/media/photoCapture/README.md deleted file mode 100644 index eb4eeac18..000000000 --- a/src/v2/components/media/photoCapture/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# solid-ui-photo-capture component - -A Lit-based camera capture web component that can render inline on a page or inside a modal dialog. It opens the device camera, lets the user take a photo, review it, retake it, and then exposes the confirmed image both as a form-like `value` and as browser events. - -## Installation - -```bash -npm install solid-ui -``` - -## Usage - -```javascript -import { PhotoCapture } from 'solid-ui/components/media/photo-capture' -``` - -The legacy flat import path `solid-ui/components/photo-capture` still works, but the grouped `media/photo-capture` path is the preferred long-term entrypoint. - -```html - - - -``` - -## API - -### Properties / attributes - -| Property | Attribute | Type | Default | Description | -|---|---|---|---|---| -| `label` | `label` | `string` | `Take Photo` | Trigger button label. Ignored when `show-trigger` is false and `presentation="inline"`. | -| `heading` | `heading` | `string` | `Take a photo` | Panel heading. | -| `presentation` | `presentation` | `'inline' \| 'dialog'` | `'inline'` | Controls whether the capture UI sits in-page or inside a native dialog. | -| `open` | `open` | `boolean` | `false` | Controls whether the capture panel is visible. | -| `name` | `name` | `string` | `''` | Form field name used when the component participates in form submission. | -| `required` | `required` | `boolean` | `false` | Marks the control as required for form validation. | -| `value` | none | `File \| null` | `null` | The current captured file. Settable from JavaScript. | -| `showTrigger` | `show-trigger` | `boolean` | `false` | Shows a trigger button that opens the capture UI. | -| `showCancelButton` | `show-cancel-button` | `boolean` | `true` | Shows the cancel and close controls. | -| `facingMode` | `facing-mode` | `string` | `environment` | Convenience control for camera selection when custom constraints are not provided. | -| `constraints` | `constraints` | `string` | `''` | JSON string for full `MediaStreamConstraints`, for example `{ "video": true }`. | -| `mediaConstraints` | none | `MediaStreamConstraints` | `undefined` | JS-only property for passing constraints directly. Overrides `constraints`. | -| `captureFormat` | `capture-format` | `string` | `image/png` | Output MIME type used for `canvas.toBlob()`. | -| `captureQuality` | `capture-quality` | `number` | `undefined` | Optional quality value for formats that support it, such as JPEG or WebP. | -| `fileNamePrefix` | `file-name-prefix` | `string` | `''` | Prefix used when the component generates a `File` name for the captured image. If omitted, the component falls back to `name` and then `photo`. | -| `autoCloseOnCapture` | `auto-close-on-capture` | `boolean` | `false` | Closes the component after the user confirms a photo. | - -### Events - -| Event | Detail | Description | -|---|---|---| -| `input` | `{ value: File \| null }` | Fired when the component updates its current file value. | -| `change` | `{ value: File \| null }` | Fired when the user confirms or clears the current file value. | -| `photo-captured` | `{ file, blob, objectUrl, contentType }` | Fired when the user confirms the captured photo. | -| `open-change` | `{ open }` | Fired whenever the component opens or closes itself. | -| `cancel` | none | Fired when the user cancels the capture flow. | -| `error` | `{ error, message }` | Fired when camera access or capture fails. | - -### Slots - -| Slot | Description | -|---|---| -| default | Replaces the trigger button label. | -| `heading` | Replaces the panel heading. | - -## Notes - -- Inline mode is the default presentation, and the panel starts closed until `open` is set or the trigger button is used. -- Dialog mode uses the native `` element and is useful when the capture flow should float above the current page. -- The component does not upload the photo itself. Consumers can persist it by reading `value`, listening for `change`, or handling `photo-captured`. -- When form-associated custom elements are supported, the component uses `ElementInternals`. Otherwise it still supports form-style submission via the form's `formdata` event. diff --git a/src/v2/components/media/photoCapture/index.ts b/src/v2/components/media/photoCapture/index.ts deleted file mode 100644 index 8c23c459a..000000000 --- a/src/v2/components/media/photoCapture/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PhotoCapture } from './PhotoCapture' - -export { PhotoCapture } - -const PHOTO_CAPTURE_TAG_NAME = 'solid-ui-photo-capture' - -if (!customElements.get(PHOTO_CAPTURE_TAG_NAME)) { - customElements.define(PHOTO_CAPTURE_TAG_NAME, PhotoCapture) -} diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 307b4c9f4..c59fe2833 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -15,6 +15,7 @@ describe('Index', () => { 'Dialog', 'DialogComponent', 'DialogTrait', + 'FormControlComponent', 'FormControlTrait', 'NoopAuth', 'ShowDialogEvent',