@refraction-ui/astro 0.15.2 โ†’ 0.16.1

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Grapheme-cluster utilities.
3
+ *
4
+ * Length limits and clamping must never bisect a user-perceived character โ€”
5
+ * a ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ ZWJ family emoji is 11 UTF-16 units but one cluster. We use
6
+ * `Intl.Segmenter` when available; the fallback treats each code point as a
7
+ * cluster (coarser, but it never splits a surrogate pair).
8
+ */
9
+
10
+ type GraphemeSegmenter = { segment(input: string): Iterable<{ segment: string; index: number }> }
11
+
12
+ let cachedSegmenter: GraphemeSegmenter | null | undefined
13
+
14
+ function getSegmenter(): GraphemeSegmenter | null {
15
+ if (cachedSegmenter === undefined) {
16
+ // Feature-detect once; Intl is the only global the module touches.
17
+ const IntlAny = typeof Intl !== 'undefined' ? (Intl as unknown as Record<string, unknown>) : undefined
18
+ const Segmenter = IntlAny?.Segmenter as
19
+ | (new (locale: undefined, opts: { granularity: 'grapheme' }) => GraphemeSegmenter)
20
+ | undefined
21
+ cachedSegmenter = Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : null
22
+ }
23
+ return cachedSegmenter
24
+ }
25
+
26
+ function isHighSurrogate(code: number): boolean {
27
+ return code >= 0xd800 && code <= 0xdbff
28
+ }
29
+
30
+ function isLowSurrogate(code: number): boolean {
31
+ return code >= 0xdc00 && code <= 0xdfff
32
+ }
33
+
34
+ /** Count of grapheme clusters (code points in the fallback), never UTF-16 units. */
35
+ export function graphemeLength(text: string): number {
36
+ if (text === '') return 0
37
+ const segmenter = getSegmenter()
38
+ if (segmenter) {
39
+ let count = 0
40
+ for (const _ of segmenter.segment(text)) count++
41
+ return count
42
+ }
43
+ // Fallback: code points (surrogate pairs count once).
44
+ let count = 0
45
+ for (const _ of text) count++
46
+ return count
47
+ }
48
+
49
+ /**
50
+ * Clamp `text` to at most `max` grapheme clusters. A cluster that would cross
51
+ * the limit is rejected whole โ€” the result is always a valid cluster boundary.
52
+ */
53
+ export function clampGraphemes(text: string, max: number): string {
54
+ if (max <= 0) return ''
55
+ const segmenter = getSegmenter()
56
+ if (segmenter) {
57
+ let count = 0
58
+ let endIndex = text.length
59
+ for (const part of segmenter.segment(text)) {
60
+ count++
61
+ if (count > max) {
62
+ endIndex = part.index
63
+ break
64
+ }
65
+ }
66
+ return text.slice(0, endIndex)
67
+ }
68
+ // Fallback: walk code points, never splitting a surrogate pair.
69
+ let count = 0
70
+ let i = 0
71
+ while (i < text.length && count < max) {
72
+ const code = text.charCodeAt(i)
73
+ i += isHighSurrogate(code) && i + 1 < text.length && isLowSurrogate(text.charCodeAt(i + 1)) ? 2 : 1
74
+ count++
75
+ }
76
+ return text.slice(0, i)
77
+ }
78
+
79
+ /** Whether `offset` sits on a grapheme-cluster boundary of `text`. */
80
+ export function isGraphemeBoundary(text: string, offset: number): boolean {
81
+ if (offset <= 0 || offset >= text.length) return true
82
+ const segmenter = getSegmenter()
83
+ if (segmenter) {
84
+ for (const part of segmenter.segment(text)) {
85
+ if (part.index === offset) return true
86
+ if (part.index > offset) return false
87
+ }
88
+ return false
89
+ }
90
+ // Fallback: only reject offsets that split a surrogate pair.
91
+ return !(isLowSurrogate(text.charCodeAt(offset)) && isHighSurrogate(text.charCodeAt(offset - 1)))
92
+ }
@@ -0,0 +1,8 @@
1
+ export * from './types.ts'
2
+ export * from './graphemes.ts'
3
+ export * from './rules.ts'
4
+ export * from './trigger-engine.ts'
5
+ export * from './suggestion.ts'
6
+ export * from './tokens.ts'
7
+ export * from './composer.ts'
8
+ export * from './styles.ts'
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Pure composer rules โ€” the single source of truth shared by the enable-check,
3
+ * the Enter handler, and the submitted payload (so they can never disagree).
4
+ */
5
+
6
+ import { graphemeLength } from './graphemes.js'
7
+ import type { CounterState } from './types.js'
8
+
9
+ /** One trim shared by the enable-check AND the sent payload. */
10
+ export function payloadFor(raw: string): string {
11
+ return raw.trim()
12
+ }
13
+
14
+ export interface CanSendArgs {
15
+ text: string
16
+ attachments?: readonly unknown[]
17
+ disabled?: boolean
18
+ readOnly?: boolean
19
+ busy?: boolean
20
+ }
21
+
22
+ /**
23
+ * Send is derived from BOTH trimmed text and attachments โ€” an attachments-only
24
+ * message is valid; a whitespace-only message never is.
25
+ */
26
+ export function canSend({ text, attachments = [], disabled = false, readOnly = false, busy = false }: CanSendArgs): boolean {
27
+ if (disabled || readOnly || busy) return false
28
+ return payloadFor(text).length > 0 || attachments.length > 0
29
+ }
30
+
31
+ /**
32
+ * The platform-matrix resolver: physical Enter sends unless Shift is held or
33
+ * an IME composition is active (composing always wins โ€” Enter confirms the
34
+ * candidate, never submits).
35
+ */
36
+ export function shouldSubmitOnEnter({
37
+ shiftPressed,
38
+ isComposing,
39
+ }: {
40
+ shiftPressed: boolean
41
+ isComposing: boolean
42
+ }): boolean {
43
+ return !shiftPressed && !isComposing
44
+ }
45
+
46
+ /** The counter surfaces once remaining budget is within this fraction of maxLength. */
47
+ export const COUNTER_VISIBLE_FRACTION = 0.2
48
+
49
+ /**
50
+ * Counter math over grapheme budget. The counter is confirmatory, not the
51
+ * enforcement mechanism (clamping at the insert boundary prevents overflow).
52
+ */
53
+ export function computeCounter(text: string, maxLength: number | undefined): CounterState {
54
+ if (maxLength === undefined) {
55
+ return { visible: false, remaining: null, overLimit: false }
56
+ }
57
+ const remaining = maxLength - graphemeLength(text)
58
+ return {
59
+ visible: remaining <= maxLength * COUNTER_VISIBLE_FRACTION,
60
+ remaining,
61
+ overLimit: remaining <= 0,
62
+ }
63
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * cva style variants for the composer surface, pill, tray, and menu.
3
+ * Boolean-ish variants use string 'true'/'false' keys per repo convention
4
+ * (raw booleans fail the dts build).
5
+ */
6
+
7
+ import { cva } from '../shared/index.ts'
8
+
9
+ /** Outer surface (the rounded card wrapping tray + field + action row). */
10
+ export const composerSurfaceVariants = cva({
11
+ base: [
12
+ 'overflow-hidden rounded-2xl border border-border bg-background shadow-sm',
13
+ 'transition-shadow focus-within:border-ring focus-within:ring-1 focus-within:ring-ring focus-within:shadow-md',
14
+ ].join(' '),
15
+ variants: {
16
+ disabled: {
17
+ true: 'pointer-events-none opacity-60',
18
+ false: '',
19
+ },
20
+ error: {
21
+ true: 'border-destructive/50',
22
+ false: '',
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ disabled: 'false',
27
+ error: 'false',
28
+ },
29
+ })
30
+
31
+ /** The text field itself (borderless inside the surface). */
32
+ export const composerFieldClass =
33
+ 'block w-full resize-none bg-transparent px-3.5 py-3 text-sm placeholder:text-muted-foreground focus:outline-none'
34
+
35
+ /** Attachment tray above the field, inside the dock. */
36
+ export const composerTrayClass = 'flex flex-wrap gap-2 px-3 pt-3'
37
+
38
+ /** A single attachment chip. */
39
+ export const composerAttachmentChipVariants = cva({
40
+ base: 'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs',
41
+ variants: {
42
+ status: {
43
+ pending: 'opacity-70',
44
+ uploading: 'opacity-70',
45
+ ready: '',
46
+ error: 'text-destructive',
47
+ },
48
+ },
49
+ defaultVariants: {
50
+ status: 'pending',
51
+ },
52
+ })
53
+
54
+ /** A committed inline token pill rendered by adapters over the flat string. */
55
+ export const composerTokenPillClass =
56
+ 'rounded bg-accent/40 px-0.5 text-accent-foreground'
57
+
58
+ /** The caret-anchored suggestion menu. */
59
+ export const composerMenuClass =
60
+ 'z-20 w-72 overflow-hidden rounded-xl border border-border bg-popover shadow-lg'
61
+
62
+ /** A suggestion row; `active` follows keyboard/pointer last-input-wins. */
63
+ export const composerMenuItemVariants = cva({
64
+ base: 'flex w-full items-center gap-2 px-3 py-2 text-left text-sm',
65
+ variants: {
66
+ active: {
67
+ true: 'bg-accent',
68
+ false: 'hover:bg-accent/50',
69
+ },
70
+ },
71
+ defaultVariants: {
72
+ active: 'false',
73
+ },
74
+ })
75
+
76
+ /** Character counter; switches to the attention treatment at/over the limit. */
77
+ export const composerCounterVariants = cva({
78
+ base: 'text-xs tabular-nums',
79
+ variants: {
80
+ overLimit: {
81
+ true: 'font-medium text-destructive',
82
+ false: 'text-muted-foreground',
83
+ },
84
+ },
85
+ defaultVariants: {
86
+ overLimit: 'false',
87
+ },
88
+ })
89
+
90
+ /** Primary action (send โ‡„ stop swap driven by `{hasText, canSend, isBusy}`). */
91
+ export const composerPrimaryActionVariants = cva({
92
+ base: 'flex h-9 w-9 items-center justify-center rounded-full transition',
93
+ variants: {
94
+ enabled: {
95
+ true: 'bg-primary text-primary-foreground hover:opacity-90',
96
+ false: 'bg-muted text-muted-foreground',
97
+ },
98
+ },
99
+ defaultVariants: {
100
+ enabled: 'false',
101
+ },
102
+ })
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Suggestion menu state (R13, R14): debounced async resolution with a
3
+ * monotonic requestToken staleness guard, delayed loading state, empty/error
4
+ * states with retry, a per-trigger circuit breaker, and keyboard navigation.
5
+ *
6
+ * Timers are plain setTimeout/clearTimeout โ€” cancelable and deterministic
7
+ * under vitest fake timers; the controller never touches DOM globals.
8
+ */
9
+
10
+ import type { ComposerCandidate, SuggestionState } from './types.js'
11
+ import type { TriggerMatch } from './trigger-engine.js'
12
+
13
+ /** Loading UI appears only if the resolver is still pending after this long. */
14
+ export const LOADING_DELAY_MS = 120
15
+ /** Deferred (blur) dismissal grace so a pointer tap on a row can land first. */
16
+ export const DISMISS_GRACE_MS = 120
17
+ /** Consecutive resolver throws for one trigger before remote calls stop for the session. */
18
+ export const CIRCUIT_BREAKER_THRESHOLD = 2
19
+
20
+ export const CLOSED_SUGGESTION_STATE: SuggestionState = {
21
+ isOpen: false,
22
+ items: [],
23
+ visibleItems: [],
24
+ activeIndex: 0,
25
+ loading: false,
26
+ error: null,
27
+ requestToken: 0,
28
+ }
29
+
30
+ export interface SuggestionController {
31
+ readonly state: SuggestionState
32
+ /** Reconcile with the current trigger match after any mutation/caret move. */
33
+ sync(match: TriggerMatch | null): void
34
+ moveNext(): void
35
+ movePrevious(): void
36
+ setActiveIndex(index: number): void
37
+ retry(): void
38
+ /** Close after a grace period (blur); a commit during the grace cancels it. */
39
+ dismissDeferred(onClosed: () => void): void
40
+ cancelDeferredDismiss(): void
41
+ /** Close immediately and invalidate any in-flight resolution. */
42
+ close(): void
43
+ isTripped(triggerId: string): boolean
44
+ destroy(): void
45
+ }
46
+
47
+ export function createSuggestionController({ emit }: { emit: () => void }): SuggestionController {
48
+ let state: SuggestionState = CLOSED_SUGGESTION_STATE
49
+ let match: TriggerMatch | null = null
50
+ let requestCounter = 0
51
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
52
+ let loadingTimer: ReturnType<typeof setTimeout> | null = null
53
+ let dismissTimer: ReturnType<typeof setTimeout> | null = null
54
+ const consecutiveFailures = new Map<string, number>()
55
+ const tripped = new Set<string>()
56
+
57
+ function clearTimer(timer: ReturnType<typeof setTimeout> | null): null {
58
+ if (timer !== null) clearTimeout(timer)
59
+ return null
60
+ }
61
+
62
+ function setState(patch: Partial<SuggestionState>): void {
63
+ state = { ...state, ...patch }
64
+ }
65
+
66
+ function sameOccurrence(a: TriggerMatch | null, b: TriggerMatch | null): boolean {
67
+ return (
68
+ a !== null &&
69
+ b !== null &&
70
+ a.trigger.id === b.trigger.id &&
71
+ a.symbolStart === b.symbolStart
72
+ )
73
+ }
74
+
75
+ function closeInternal(): void {
76
+ debounceTimer = clearTimer(debounceTimer)
77
+ loadingTimer = clearTimer(loadingTimer)
78
+ // Bumping the counter invalidates any in-flight resolution (D13).
79
+ requestCounter++
80
+ match = null
81
+ state = CLOSED_SUGGESTION_STATE
82
+ }
83
+
84
+ function applyResults(forMatch: TriggerMatch, items: ComposerCandidate[]): void {
85
+ loadingTimer = clearTimer(loadingTimer)
86
+ consecutiveFailures.set(forMatch.trigger.id, 0)
87
+ setState({
88
+ isOpen: true,
89
+ items,
90
+ visibleItems: items.slice(0, forMatch.trigger.maxVisibleResults),
91
+ activeIndex: 0,
92
+ loading: false,
93
+ error: null,
94
+ })
95
+ }
96
+
97
+ function applyFailure(forMatch: TriggerMatch, error: unknown): void {
98
+ loadingTimer = clearTimer(loadingTimer)
99
+ const triggerId = forMatch.trigger.id
100
+ const failures = (consecutiveFailures.get(triggerId) ?? 0) + 1
101
+ consecutiveFailures.set(triggerId, failures)
102
+ if (failures >= CIRCUIT_BREAKER_THRESHOLD) {
103
+ // Session fallback: stop resolving remotely, close silently (D6).
104
+ tripped.add(triggerId)
105
+ closeInternal()
106
+ return
107
+ }
108
+ setState({
109
+ isOpen: true,
110
+ items: [],
111
+ visibleItems: [],
112
+ loading: false,
113
+ error: error instanceof Error ? error.message : 'Failed to load suggestions',
114
+ })
115
+ }
116
+
117
+ /**
118
+ * `notifySync` โ€” emit() after a synchronously-settled resolve. False when a
119
+ * composer mutation is already in flight (it emits once at the end); true
120
+ * when invoked from a timer, where nobody else will notify.
121
+ */
122
+ function dispatch(forMatch: TriggerMatch, notifySync: boolean): void {
123
+ const token = ++requestCounter
124
+ setState({ requestToken: token })
125
+ let result: ComposerCandidate[] | Promise<ComposerCandidate[]>
126
+ try {
127
+ result = forMatch.trigger.resolve(forMatch.query)
128
+ } catch (error) {
129
+ applyFailure(forMatch, error)
130
+ if (notifySync) emit()
131
+ return
132
+ }
133
+ if (Array.isArray(result)) {
134
+ applyResults(forMatch, result)
135
+ if (notifySync) emit()
136
+ return
137
+ }
138
+ // Async: show the loading state only if it is still pending after 120ms.
139
+ loadingTimer = clearTimer(loadingTimer)
140
+ loadingTimer = setTimeout(() => {
141
+ if (token !== requestCounter) return
142
+ setState({ isOpen: true, loading: true })
143
+ emit()
144
+ }, LOADING_DELAY_MS)
145
+ result.then(
146
+ (items) => {
147
+ // Staleness guard: a slow response must never overwrite a newer one,
148
+ // and results arriving after the trigger closed are ignored.
149
+ if (token !== requestCounter || !sameOccurrence(match, forMatch)) return
150
+ applyResults(forMatch, items)
151
+ emit()
152
+ },
153
+ (error) => {
154
+ if (token !== requestCounter || !sameOccurrence(match, forMatch)) return
155
+ applyFailure(forMatch, error)
156
+ emit()
157
+ },
158
+ )
159
+ }
160
+
161
+ function schedule(forMatch: TriggerMatch): void {
162
+ debounceTimer = clearTimer(debounceTimer)
163
+ if (forMatch.trigger.debounceMs <= 0) {
164
+ dispatch(forMatch, false)
165
+ return
166
+ }
167
+ debounceTimer = setTimeout(() => {
168
+ debounceTimer = null
169
+ if (sameOccurrence(match, forMatch) && match?.query === forMatch.query) {
170
+ dispatch(forMatch, true)
171
+ }
172
+ }, forMatch.trigger.debounceMs)
173
+ }
174
+
175
+ return {
176
+ get state() {
177
+ return state
178
+ },
179
+
180
+ sync(next: TriggerMatch | null): void {
181
+ if (next === null) {
182
+ if (match !== null || state.isOpen) closeInternal()
183
+ return
184
+ }
185
+ if (tripped.has(next.trigger.id)) {
186
+ if (match !== null || state.isOpen) closeInternal()
187
+ return
188
+ }
189
+ const isSame = sameOccurrence(match, next)
190
+ const queryChanged = !isSame || match?.query !== next.query
191
+ match = next
192
+ if (!isSame) {
193
+ // Fresh occurrence: start from a clean open menu.
194
+ state = { ...CLOSED_SUGGESTION_STATE, isOpen: true, requestToken: state.requestToken }
195
+ }
196
+ if (queryChanged) schedule(next)
197
+ },
198
+
199
+ moveNext(): void {
200
+ const count = state.items.length
201
+ if (!state.isOpen || count === 0) return
202
+ const wrap = match?.trigger.wrapNavigation ?? true
203
+ const next = wrap
204
+ ? (state.activeIndex + 1) % count
205
+ : Math.min(state.activeIndex + 1, count - 1)
206
+ setState({ activeIndex: next })
207
+ },
208
+
209
+ movePrevious(): void {
210
+ const count = state.items.length
211
+ if (!state.isOpen || count === 0) return
212
+ const wrap = match?.trigger.wrapNavigation ?? true
213
+ const previous = wrap
214
+ ? (state.activeIndex - 1 + count) % count
215
+ : Math.max(state.activeIndex - 1, 0)
216
+ setState({ activeIndex: previous })
217
+ },
218
+
219
+ setActiveIndex(index: number): void {
220
+ if (!state.isOpen || state.items.length === 0) return
221
+ setState({ activeIndex: Math.max(0, Math.min(index, state.items.length - 1)) })
222
+ },
223
+
224
+ retry(): void {
225
+ if (match === null) return
226
+ if (tripped.has(match.trigger.id)) return
227
+ setState({ error: null })
228
+ dispatch(match, false)
229
+ },
230
+
231
+ dismissDeferred(onClosed: () => void): void {
232
+ dismissTimer = clearTimer(dismissTimer)
233
+ dismissTimer = setTimeout(() => {
234
+ dismissTimer = null
235
+ closeInternal()
236
+ onClosed()
237
+ }, DISMISS_GRACE_MS)
238
+ },
239
+
240
+ cancelDeferredDismiss(): void {
241
+ dismissTimer = clearTimer(dismissTimer)
242
+ },
243
+
244
+ close(): void {
245
+ dismissTimer = clearTimer(dismissTimer)
246
+ closeInternal()
247
+ },
248
+
249
+ isTripped(triggerId: string): boolean {
250
+ return tripped.has(triggerId)
251
+ },
252
+
253
+ destroy(): void {
254
+ debounceTimer = clearTimer(debounceTimer)
255
+ loadingTimer = clearTimer(loadingTimer)
256
+ dismissTimer = clearTimer(dismissTimer)
257
+ match = null
258
+ state = CLOSED_SUGGESTION_STATE
259
+ },
260
+ }
261
+ }