@refraction-ui/astro 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/astro-button/Button.stories.ts +4 -0
- package/dist/astro-button/index.ts +8 -1
- package/dist/astro-composer/Composer.astro +3 -1
- package/dist/astro-composer/Composer.stories.ts +6 -0
- package/dist/astro-composer/InteractiveComposer.astro +57 -0
- package/dist/astro-composer/InteractiveComposer.stories.ts +8 -0
- package/dist/astro-composer/index.ts +3 -1
- package/dist/astro-composer/interactive-composer.ts +137 -0
- package/dist/composer/clipboard.ts +28 -0
- package/dist/composer/index.ts +2 -0
- package/dist/composer/paste-field.ts +53 -0
- package/package.json +1 -1
|
@@ -16,3 +16,7 @@ export const Default = {
|
|
|
16
16
|
disabled: false
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
export const NamedIcon = {
|
|
21
|
+
args: { size: 'icon', variant: 'ghost', 'aria-label': 'Dictate', default: '<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16"><path d="M12 2v16" stroke="currentColor" /></svg>' },
|
|
22
|
+
}
|
|
@@ -37,8 +37,10 @@ const hasContent = Astro.slots.has('default')
|
|
|
37
37
|
<div class={cn(composerFieldClass, 'min-h-[3rem] whitespace-pre-wrap break-words')}>
|
|
38
38
|
{hasContent ? <slot /> : <span class="text-muted-foreground">{placeholder}</span>}
|
|
39
39
|
</div>
|
|
40
|
-
<div class="flex items-center px-2 pb-2">
|
|
40
|
+
<div class="flex items-center gap-0.5 px-2 pb-2">
|
|
41
|
+
<slot name="leading" />
|
|
41
42
|
<div class="flex-1" />
|
|
43
|
+
<slot name="trailing" />
|
|
42
44
|
<button
|
|
43
45
|
type="button"
|
|
44
46
|
aria-label={sendLabel}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { composerFieldClass, composerSurfaceVariants, composerTrayClass } from '../composer/index.ts'
|
|
3
|
+
import { cn } from '../shared/index.ts'
|
|
4
|
+
|
|
5
|
+
interface Props extends astroHTML.JSX.HTMLAttributes {
|
|
6
|
+
defaultValue?: string
|
|
7
|
+
placeholder?: string
|
|
8
|
+
label?: string
|
|
9
|
+
messageLabel?: string
|
|
10
|
+
sendLabel?: string
|
|
11
|
+
removeLabel?: string
|
|
12
|
+
pasteFailedNotice?: string
|
|
13
|
+
trimmedNotice?: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
readOnly?: boolean
|
|
16
|
+
busy?: boolean
|
|
17
|
+
maxLength?: number
|
|
18
|
+
maxAttachments?: number
|
|
19
|
+
maxAttachmentSizeBytes?: number
|
|
20
|
+
/** Opt-in keyboard submission; false preserves Enter for newlines on mobile. */
|
|
21
|
+
submitOnEnter?: boolean
|
|
22
|
+
}
|
|
23
|
+
const {
|
|
24
|
+
defaultValue = '', placeholder = 'Send a message…', label = 'Message composer',
|
|
25
|
+
messageLabel = 'Message', sendLabel = 'Send', removeLabel = 'Remove attachment',
|
|
26
|
+
pasteFailedNotice = 'Could not paste images. Try attaching the file instead.',
|
|
27
|
+
trimmedNotice = 'Pasted text was trimmed to fit the length limit',
|
|
28
|
+
disabled = false, readOnly = false, busy = false, submitOnEnter = false,
|
|
29
|
+
maxLength, maxAttachments, maxAttachmentSizeBytes, class: className, ...rest
|
|
30
|
+
} = Astro.props
|
|
31
|
+
---
|
|
32
|
+
<refraction-interactive-composer
|
|
33
|
+
role="form" aria-label={label} class={cn('block relative', className)}
|
|
34
|
+
disabled={disabled || undefined} read-only={readOnly || undefined} busy={busy || undefined}
|
|
35
|
+
submit-on-enter={submitOnEnter || undefined} max-length={maxLength}
|
|
36
|
+
max-attachments={maxAttachments} max-image-bytes={maxAttachmentSizeBytes}
|
|
37
|
+
data-remove-label={removeLabel} data-paste-failed-notice={pasteFailedNotice}
|
|
38
|
+
data-trimmed-notice={trimmedNotice} {...rest}
|
|
39
|
+
>
|
|
40
|
+
<div class={composerSurfaceVariants({ disabled: disabled ? 'true' : 'false' })}>
|
|
41
|
+
<div data-notice role="status" aria-live="polite" class="px-3 text-xs text-muted-foreground"></div>
|
|
42
|
+
<div data-tray class={composerTrayClass}></div>
|
|
43
|
+
<textarea aria-label={messageLabel} placeholder={placeholder} disabled={disabled} readonly={readOnly}
|
|
44
|
+
rows="3" class={cn(composerFieldClass, 'block w-full resize-y')}>{defaultValue}</textarea>
|
|
45
|
+
<div class="flex items-center justify-between gap-2 px-3 pb-3">
|
|
46
|
+
<div class="flex min-w-0 items-center gap-0.5"><slot name="leading" /></div>
|
|
47
|
+
<div class="flex items-center gap-2">
|
|
48
|
+
<slot name="trailing" />
|
|
49
|
+
<button data-send type="button" disabled class="rounded-full bg-primary px-4 py-2 text-primary-foreground">{sendLabel}</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</refraction-interactive-composer>
|
|
54
|
+
<script>
|
|
55
|
+
import { registerRefractionInteractiveComposer } from './interactive-composer.js'
|
|
56
|
+
registerRefractionInteractiveComposer()
|
|
57
|
+
</script>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import Component from './InteractiveComposer.astro'
|
|
2
|
+
import ToolbarComposition from '../../astro-meta/__tests__/compositions/interactive-composer-toolbar.astro'
|
|
3
|
+
const meta = { title: 'Astro/Composer/Interactive', component: Component }
|
|
4
|
+
export default meta
|
|
5
|
+
export const Clipboard = { args: { defaultValue: 'Local fixture draft', placeholder: 'Paste text and PNG/JPEG images…' } }
|
|
6
|
+
export const ReadOnly = { args: { defaultValue: 'Selectable draft', readOnly: true } }
|
|
7
|
+
export const Disabled = { args: { disabled: true } }
|
|
8
|
+
export const Toolbar = { render: () => ToolbarComposition }
|
|
@@ -1 +1,3 @@
|
|
|
1
|
-
export {}
|
|
1
|
+
export { default as RefractionInteractiveComposer } from './InteractiveComposer.astro'
|
|
2
|
+
export type { RefractionComposerImagePasteDetail, RefractionInteractiveComposerElement } from './interactive-composer.js'
|
|
3
|
+
export { default as Composer } from './Composer.astro'
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { composerClipboardImages, createComposer, pasteComposerField } from '../composer/index.ts'
|
|
2
|
+
import type { ComposerAPI, ComposerState, ComposerSubmission } from '../composer/index.ts'
|
|
3
|
+
|
|
4
|
+
export interface RefractionComposerImagePasteDetail {
|
|
5
|
+
images: File[]
|
|
6
|
+
/** Host stages/uploads via this same core; bytes never enter serialized drafts. */
|
|
7
|
+
api: ComposerAPI
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RefractionInteractiveComposerElement extends HTMLElement {
|
|
11
|
+
readonly api: ComposerAPI | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Install once; custom-element lifecycle also supports Astro view transitions. */
|
|
15
|
+
export function registerRefractionInteractiveComposer(): void {
|
|
16
|
+
if (customElements.get('refraction-interactive-composer')) return
|
|
17
|
+
customElements.define('refraction-interactive-composer', class extends HTMLElement {
|
|
18
|
+
api?: ComposerAPI
|
|
19
|
+
private controller?: AbortController
|
|
20
|
+
private unsubscribe?: () => void
|
|
21
|
+
private pasting = false
|
|
22
|
+
|
|
23
|
+
connectedCallback(): void {
|
|
24
|
+
if (this.api) return
|
|
25
|
+
const field = this.querySelector('textarea')!
|
|
26
|
+
const send = this.querySelector<HTMLButtonElement>('[data-send]')!
|
|
27
|
+
const tray = this.querySelector<HTMLElement>('[data-tray]')!
|
|
28
|
+
const notice = this.querySelector<HTMLElement>('[data-notice]')!
|
|
29
|
+
const emit = (name: string, detail: unknown) => this.dispatchEvent(new CustomEvent(`refraction:${name}`, { bubbles: true, detail }))
|
|
30
|
+
const number = (name: string) => this.hasAttribute(name) ? Number(this.getAttribute(name)) : undefined
|
|
31
|
+
const api = createComposer({
|
|
32
|
+
initialValue: field.value,
|
|
33
|
+
maxLength: number('max-length'),
|
|
34
|
+
maxAttachments: number('max-attachments'),
|
|
35
|
+
maxAttachmentSizeBytes: number('max-image-bytes'),
|
|
36
|
+
onEvent: event => {
|
|
37
|
+
if (event.type === 'paste-trimmed') notice.textContent = this.dataset.trimmedNotice ?? ''
|
|
38
|
+
emit('composer-event', event)
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
this.api = api
|
|
42
|
+
const controller = new AbortController()
|
|
43
|
+
this.controller = controller
|
|
44
|
+
const options = { signal: controller.signal }
|
|
45
|
+
const render = (state: ComposerState) => {
|
|
46
|
+
if (!this.pasting && field.value !== state.value) field.value = state.value
|
|
47
|
+
field.disabled = state.disabled
|
|
48
|
+
field.readOnly = state.readOnly
|
|
49
|
+
send.disabled = !state.canSend
|
|
50
|
+
send.setAttribute('aria-busy', String(state.isBusy))
|
|
51
|
+
tray.replaceChildren()
|
|
52
|
+
for (const attachment of state.attachments) {
|
|
53
|
+
const chip = document.createElement('span')
|
|
54
|
+
chip.className = 'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm'
|
|
55
|
+
const label = document.createElement('span')
|
|
56
|
+
label.textContent = `${attachment.name} · ${attachment.status}`
|
|
57
|
+
chip.append(label)
|
|
58
|
+
const remove = document.createElement('button')
|
|
59
|
+
remove.type = 'button'
|
|
60
|
+
remove.textContent = '×'
|
|
61
|
+
remove.setAttribute('aria-label', `${this.dataset.removeLabel} ${attachment.name}`)
|
|
62
|
+
remove.disabled = state.disabled || state.readOnly || state.isBusy
|
|
63
|
+
remove.dataset.attachmentId = attachment.id
|
|
64
|
+
chip.append(remove)
|
|
65
|
+
tray.append(chip)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
tray.addEventListener('click', event => {
|
|
69
|
+
const button = (event.target as Element).closest<HTMLButtonElement>('[data-attachment-id]')
|
|
70
|
+
if (!button || button.disabled) return
|
|
71
|
+
const attachment = api.getState().attachments.find(item => item.id === button.dataset.attachmentId)
|
|
72
|
+
if (!attachment) return
|
|
73
|
+
api.removeAttachment(attachment.id)
|
|
74
|
+
emit('attachment-remove', attachment)
|
|
75
|
+
}, options)
|
|
76
|
+
const selection = () => api.setSelection({ start: field.selectionStart, end: field.selectionEnd })
|
|
77
|
+
this.unsubscribe = api.subscribe(render)
|
|
78
|
+
field.addEventListener('input', () => {
|
|
79
|
+
if (!this.pasting) api.setValue(field.value, { start: field.selectionStart, end: field.selectionEnd })
|
|
80
|
+
}, options)
|
|
81
|
+
field.addEventListener('select', () => { if (!this.pasting) selection() }, options)
|
|
82
|
+
field.addEventListener('compositionstart', () => api.setComposing(true), options)
|
|
83
|
+
field.addEventListener('compositionend', () => api.setComposing(false), options)
|
|
84
|
+
field.addEventListener('paste', event => {
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
const state = api.getState()
|
|
87
|
+
if (state.disabled || state.readOnly || state.isComposing) return
|
|
88
|
+
notice.textContent = ''
|
|
89
|
+
let images: File[]
|
|
90
|
+
let text: string
|
|
91
|
+
try {
|
|
92
|
+
images = composerClipboardImages(Array.from(event.clipboardData?.files ?? []), number('max-image-bytes'))
|
|
93
|
+
text = event.clipboardData?.getData('text/plain') ?? ''
|
|
94
|
+
} catch (error) {
|
|
95
|
+
notice.textContent = this.dataset.pasteFailedNotice ?? ''
|
|
96
|
+
emit('paste-error', { error })
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
this.pasting = true
|
|
100
|
+
try {
|
|
101
|
+
pasteComposerField(api, field, text, inserted => typeof document.execCommand === 'function' && document.execCommand('insertText', false, inserted))
|
|
102
|
+
} finally {
|
|
103
|
+
this.pasting = false
|
|
104
|
+
}
|
|
105
|
+
if (images.length > 0) emit('images-pasted', { images, api } satisfies RefractionComposerImagePasteDetail)
|
|
106
|
+
}, options)
|
|
107
|
+
const submit = () => {
|
|
108
|
+
const submission = api.submit()
|
|
109
|
+
if (submission) emit('submit', submission satisfies ComposerSubmission)
|
|
110
|
+
}
|
|
111
|
+
send.addEventListener('click', submit, options)
|
|
112
|
+
field.addEventListener('keydown', event => {
|
|
113
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && this.hasAttribute('submit-on-enter')) {
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
submit()
|
|
116
|
+
}
|
|
117
|
+
}, options)
|
|
118
|
+
this.syncFlags()
|
|
119
|
+
render(api.getState())
|
|
120
|
+
emit('composer-ready', { api })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static get observedAttributes(): string[] { return ['disabled', 'read-only', 'busy'] }
|
|
124
|
+
attributeChangedCallback(): void { this.syncFlags() }
|
|
125
|
+
private syncFlags(): void {
|
|
126
|
+
this.api?.setDisabled(this.hasAttribute('disabled'))
|
|
127
|
+
this.api?.setReadOnly(this.hasAttribute('read-only'))
|
|
128
|
+
this.api?.setBusy(this.hasAttribute('busy'))
|
|
129
|
+
}
|
|
130
|
+
disconnectedCallback(): void {
|
|
131
|
+
this.controller?.abort()
|
|
132
|
+
this.unsubscribe?.()
|
|
133
|
+
this.api?.destroy()
|
|
134
|
+
this.api = undefined
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Clipboard payload rules shared by browser adapters; no clipboard access. */
|
|
2
|
+
export const DEFAULT_COMPOSER_IMAGE_MAX_BYTES = 100 * 1024 * 1024
|
|
3
|
+
|
|
4
|
+
export interface ComposerClipboardFile {
|
|
5
|
+
readonly name: string
|
|
6
|
+
readonly type: string
|
|
7
|
+
readonly size: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ComposerImagePasteError extends Error {
|
|
11
|
+
constructor(readonly reason: 'empty' | 'max-size', readonly fileName: string) {
|
|
12
|
+
super(`Cannot paste image ${fileName}: ${reason}`)
|
|
13
|
+
this.name = 'ComposerImagePasteError'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** PNG/JPEG only. Reject the entire image batch before handing anything to a host. */
|
|
18
|
+
export function composerClipboardImages<T extends ComposerClipboardFile>(
|
|
19
|
+
files: Iterable<T>,
|
|
20
|
+
maxBytes = DEFAULT_COMPOSER_IMAGE_MAX_BYTES,
|
|
21
|
+
): T[] {
|
|
22
|
+
const images = Array.from(files).filter(file => file.type === 'image/png' || file.type === 'image/jpeg')
|
|
23
|
+
for (const image of images) {
|
|
24
|
+
if (image.size === 0) throw new ComposerImagePasteError('empty', image.name)
|
|
25
|
+
if (image.size > maxBytes) throw new ComposerImagePasteError('max-size', image.name)
|
|
26
|
+
}
|
|
27
|
+
return images
|
|
28
|
+
}
|
package/dist/composer/index.ts
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ComposerAPI } from './types.js'
|
|
2
|
+
|
|
3
|
+
/** Structural field seam: the headless package never reads browser globals. */
|
|
4
|
+
export interface ComposerPasteField {
|
|
5
|
+
value: string
|
|
6
|
+
selectionStart: number
|
|
7
|
+
selectionEnd: number
|
|
8
|
+
setSelectionRange(start: number, end: number): void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Native replacement ranges must not bisect a UTF-16 surrogate pair.
|
|
12
|
+
function splitsSurrogate(text: string, offset: number): boolean {
|
|
13
|
+
return /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/.test(text.slice(offset - 1, offset + 1))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply the core's token/grapheme rules, then mirror its edit as a native undo
|
|
18
|
+
* transaction when the adapter supplies one. The adapter must defer rendering
|
|
19
|
+
* the field until this call returns. Empty image-only pastes preserve selection.
|
|
20
|
+
*/
|
|
21
|
+
export function pasteComposerField(
|
|
22
|
+
api: ComposerAPI,
|
|
23
|
+
field: ComposerPasteField,
|
|
24
|
+
text: string,
|
|
25
|
+
insertText?: (text: string) => boolean,
|
|
26
|
+
): void {
|
|
27
|
+
api.setSelection({ start: field.selectionStart, end: field.selectionEnd })
|
|
28
|
+
if (text === '') return
|
|
29
|
+
const before = field.value
|
|
30
|
+
api.pasteText(text)
|
|
31
|
+
const after = api.getState()
|
|
32
|
+
if (after.value !== before) {
|
|
33
|
+
let start = 0
|
|
34
|
+
while (start < before.length && start < after.value.length && before[start] === after.value[start]) start++
|
|
35
|
+
if (splitsSurrogate(before, start) || splitsSurrogate(after.value, start)) start--
|
|
36
|
+
let end = before.length
|
|
37
|
+
let nextEnd = after.value.length
|
|
38
|
+
while (end > start && nextEnd > start && before[end - 1] === after.value[nextEnd - 1]) {
|
|
39
|
+
end--
|
|
40
|
+
nextEnd--
|
|
41
|
+
}
|
|
42
|
+
if (splitsSurrogate(before, end) || splitsSurrogate(after.value, nextEnd)) {
|
|
43
|
+
end++
|
|
44
|
+
nextEnd++
|
|
45
|
+
}
|
|
46
|
+
field.setSelectionRange(start, end)
|
|
47
|
+
insertText?.(after.value.slice(start, nextEnd))
|
|
48
|
+
// Non-browser tests and browsers without native insertion still retain the
|
|
49
|
+
// core's value/selection rules; native undo is verified separately.
|
|
50
|
+
if (field.value !== after.value) field.value = after.value
|
|
51
|
+
}
|
|
52
|
+
field.setSelectionRange(after.selection.start, after.selection.end)
|
|
53
|
+
}
|