@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.
@@ -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
+ }
@@ -1 +1,8 @@
1
- export {}
1
+ export { default as Button } from './Button.astro'
2
+ // Shared classes let native triggers use button styling without nested buttons.
3
+ export {
4
+ buttonVariants,
5
+ buttonTokens,
6
+ type ButtonVariant,
7
+ type ButtonSize,
8
+ } from '../button/index.ts'
@@ -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,6 @@
1
+ import ToolbarExample from '../../astro-meta/__tests__/compositions/composer.astro'
2
+
3
+ const meta = { title: 'Chat & AI/Composer', component: ToolbarExample }
4
+ export default meta
5
+
6
+ export const IconToolbar = {}
@@ -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
+ }
@@ -7,3 +7,5 @@ export * from './tokens.ts'
7
7
  export * from './composer.ts'
8
8
  export * from './styles.ts'
9
9
  export { createEmojiTrigger, type EmojiTriggerOptions } from './emoji-trigger.js'
10
+ export * from './clipboard.ts'
11
+ export * from './paste-field.ts'
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@refraction-ui/astro",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "All Refraction UI Astro components in one package",
5
5
  "type": "module",
6
6
  "exports": {