@refraction-ui/astro 0.15.2 โ 0.16.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-composer/Composer.astro +66 -0
- package/dist/astro-composer/index.ts +1 -0
- package/dist/composer/composer.ts +799 -0
- package/dist/composer/graphemes.ts +92 -0
- package/dist/composer/index.ts +8 -0
- package/dist/composer/rules.ts +63 -0
- package/dist/composer/styles.ts +102 -0
- package/dist/composer/suggestion.ts +261 -0
- package/dist/composer/tokens.ts +288 -0
- package/dist/composer/trigger-engine.ts +205 -0
- package/dist/composer/types.ts +337 -0
- package/dist/index.ts +1 -0
- package/dist/rich-editor/index.ts +4 -0
- package/dist/rich-editor/mentions.ts +3 -17
- package/dist/rich-editor/slash-commands.ts +3 -17
- package/dist/rich-editor/trigger.ts +40 -0
- package/package.json +2 -1
|
@@ -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,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-foreground/25 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
|
+
}
|