@pgcorp/ui-kit 0.8.1 → 0.9.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.
@@ -21,6 +21,8 @@
21
21
  :data-monospace="monospace || undefined"
22
22
  :data-auto-resize="resizeContract.mode === 'content' || undefined"
23
23
  :data-max-rows="validatedMaxRows"
24
+ :data-min-height="validatedMinHeight"
25
+ :data-max-height="validatedMaxHeight"
24
26
  :data-invalid="field.invalid || undefined"
25
27
  :value="modelValue"
26
28
  :placeholder="placeholder"
@@ -31,25 +33,36 @@
31
33
  :aria-required="field.required || undefined"
32
34
  :aria-readonly="field.readonly || undefined"
33
35
  :aria-describedby="mergeIdReferences(ownedAttrs.string('aria-describedby'), field.describedBy)"
36
+ :aria-controls="validatedSuggestions?.controls"
37
+ :aria-activedescendant="validatedSuggestions?.activeDescendant"
38
+ :aria-autocomplete="validatedSuggestions?.autocomplete"
39
+ :aria-haspopup="validatedSuggestions?.popupRole"
34
40
  :rows="validatedRows"
35
41
  @input="handleInput"
36
- @keydown="emit('keydown', $event)"
42
+ @keydown="handleKeydown"
37
43
  @keyup="handleKeyup"
38
44
  @click="handleClick"
45
+ @select="handleSelectionChange"
46
+ @paste="emit('paste', $event)"
47
+ @compositionstart="handleCompositionStart"
48
+ @compositionupdate="handleCompositionUpdate"
49
+ @compositionend="handleCompositionEnd"
39
50
  />
40
51
  </template>
41
52
  </SField>
42
53
  </template>
43
54
 
44
55
  <script setup lang="ts">
45
- import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
56
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
46
57
  import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
47
58
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
48
59
  import { resolveFieldSurfaceBindings, type FieldSurfaceBindings } from '../../../internal/fieldSurface'
49
60
  import {
50
61
  resolveOptionalDomId,
51
62
  validateBoolean,
63
+ validateBoundedNumber,
52
64
  validateBoundedInteger,
65
+ validateDomId,
53
66
  validateExactString,
54
67
  } from '../../../internal/runtimeContract'
55
68
  import SField from './SField.vue'
@@ -61,6 +74,47 @@ defineOptions({
61
74
  /** Визуальная интеграция native textarea с SField. / Native textarea visual integration with SField. */
62
75
  export type STextareaAppearance = 'default' | 'ghost'
63
76
  export type STextareaSize = 'sm' | 'md' | 'lg'
77
+ export type STextareaSelectionDirection = 'forward' | 'backward' | 'none'
78
+ export type STextareaAutocomplete = 'list' | 'both' | 'inline' | 'none'
79
+ export type STextareaSuggestionPopupRole = 'listbox' | 'grid' | 'tree' | 'dialog'
80
+
81
+ /** Полный native selection contract без сведения диапазона к одному cursor. */
82
+ export interface STextareaSelection {
83
+ start: number
84
+ end: number
85
+ direction: STextareaSelectionDirection
86
+ }
87
+
88
+ /** Связь multiline textbox с consumer-owned suggestion popup без недопустимого role="combobox" на textarea. */
89
+ export interface STextareaSuggestions {
90
+ controls: string
91
+ activeDescendant?: string
92
+ autocomplete?: STextareaAutocomplete
93
+ popupRole?: STextareaSuggestionPopupRole
94
+ }
95
+
96
+ export interface STextareaState {
97
+ text: string
98
+ cursor: number
99
+ selection: STextareaSelection
100
+ composing: boolean
101
+ }
102
+
103
+ export interface STextareaReplaceOptions {
104
+ selection?: STextareaSelection
105
+ selectionMode?: SelectionMode
106
+ focus?: boolean
107
+ }
108
+
109
+ interface STextareaHistorySnapshot {
110
+ text: string
111
+ selection: STextareaSelection
112
+ }
113
+
114
+ interface STextareaReplacementHistoryEntry {
115
+ before: STextareaHistorySnapshot
116
+ after: STextareaHistorySnapshot
117
+ }
64
118
 
65
119
  /**
66
120
  * Дискриминированный resize contract: fixed не имеет maxRows, content может быть bounded или unbounded.
@@ -68,11 +122,23 @@ export type STextareaSize = 'sm' | 'md' | 'lg'
68
122
  */
69
123
  export type STextareaResizeContract =
70
124
  | { mode: 'fixed'; autoResize: false }
71
- | { mode: 'content'; autoResize: true; maxRows?: number }
125
+ | {
126
+ mode: 'content'
127
+ autoResize: true
128
+ maxRows?: number
129
+ minHeight?: number
130
+ maxHeight?: number
131
+ }
72
132
 
73
133
  const TEXTAREA_APPEARANCES = ['default', 'ghost'] as const satisfies readonly STextareaAppearance[]
74
134
  const TEXTAREA_SIZES = ['sm', 'md', 'lg'] as const satisfies readonly STextareaSize[]
75
135
  const TEXTAREA_ROWS_DOMAIN = { minimum: 1, maximum: 100 } as const
136
+ const TEXTAREA_HEIGHT_DOMAIN = { minimum: 1, maximum: 4096 } as const
137
+ const TEXTAREA_AUTOCOMPLETE = ['list', 'both', 'inline', 'none'] as const satisfies readonly STextareaAutocomplete[]
138
+ const TEXTAREA_SUGGESTION_POPUP_ROLES = ['listbox', 'grid', 'tree', 'dialog'] as const satisfies readonly STextareaSuggestionPopupRole[]
139
+ const TEXTAREA_SELECTION_DIRECTIONS = ['forward', 'backward', 'none'] as const satisfies readonly STextareaSelectionDirection[]
140
+ const TEXTAREA_SELECTION_MODES = ['select', 'start', 'end', 'preserve'] as const satisfies readonly SelectionMode[]
141
+ const PROGRAMMATIC_HISTORY_LIMIT = 100
76
142
 
77
143
  export interface Props {
78
144
  modelValue: string | null
@@ -89,6 +155,9 @@ export interface Props {
89
155
  size?: STextareaSize
90
156
  autoResize?: boolean
91
157
  maxRows?: number
158
+ minHeight?: number
159
+ maxHeight?: number
160
+ suggestions?: STextareaSuggestions
92
161
  appearance?: STextareaAppearance
93
162
  monospace?: boolean
94
163
  }
@@ -108,6 +177,9 @@ const props = withDefaults(defineProps<Props>(), {
108
177
  size: 'md',
109
178
  autoResize: false,
110
179
  maxRows: undefined,
180
+ minHeight: undefined,
181
+ maxHeight: undefined,
182
+ suggestions: undefined,
111
183
  appearance: 'default',
112
184
  monospace: false,
113
185
  })
@@ -117,7 +189,12 @@ const emit = defineEmits<{
117
189
  (event: 'keydown', keyboardEvent: KeyboardEvent): void
118
190
  (event: 'keyup', keyboardEvent: KeyboardEvent): void
119
191
  (event: 'click', mouseEvent: MouseEvent): void
120
- (event: 'state-change', state: { text: string; cursor: number }): void
192
+ (event: 'paste', clipboardEvent: ClipboardEvent): void
193
+ (event: 'compositionstart', compositionEvent: CompositionEvent): void
194
+ (event: 'compositionupdate', compositionEvent: CompositionEvent): void
195
+ (event: 'compositionend', compositionEvent: CompositionEvent): void
196
+ (event: 'selection-change', selection: STextareaSelection): void
197
+ (event: 'state-change', state: STextareaState): void
121
198
  }>()
122
199
 
123
200
  const ownedAttrs = useOwnedAttrs({ component: 'STextarea', owner: 'native textarea' })
@@ -130,6 +207,14 @@ const textareaId = computed(() => resolveOptionalDomId(
130
207
  `s-textarea-${generatedTextareaId}`,
131
208
  ))
132
209
  const textareaRef = ref<HTMLTextAreaElement | null>(null)
210
+ const composing = ref(false)
211
+ let resizeObserver: ResizeObserver | null = null
212
+ let observedResizeOwner: HTMLElement | null = null
213
+ let observedWidth: number | null = null
214
+ let resizeFrame: number | null = null
215
+ let applyingProgrammaticReplacement = false
216
+ const replacementUndoStack: STextareaReplacementHistoryEntry[] = []
217
+ const replacementRedoStack: STextareaReplacementHistoryEntry[] = []
133
218
  const validatedAppearance = computed<STextareaAppearance>(() => validateExactString(
134
219
  'STextarea',
135
220
  'appearance',
@@ -151,15 +236,66 @@ const validatedRows = computed(() => validateBoundedInteger(
151
236
  const validatedMaxRows = computed(() => props.maxRows === undefined
152
237
  ? undefined
153
238
  : validateBoundedInteger('STextarea', 'maxRows', props.maxRows, TEXTAREA_ROWS_DOMAIN))
239
+ const validatedMinHeight = computed(() => props.minHeight === undefined
240
+ ? undefined
241
+ : validateBoundedNumber('STextarea', 'minHeight', props.minHeight, TEXTAREA_HEIGHT_DOMAIN))
242
+ const validatedMaxHeight = computed(() => props.maxHeight === undefined
243
+ ? undefined
244
+ : validateBoundedNumber('STextarea', 'maxHeight', props.maxHeight, TEXTAREA_HEIGHT_DOMAIN))
245
+ const validatedSuggestions = computed<STextareaSuggestions | undefined>(() => {
246
+ if (props.suggestions === undefined) return undefined
247
+ if (props.suggestions === null || typeof props.suggestions !== 'object' || Array.isArray(props.suggestions)) {
248
+ throw new TypeError('STextarea: suggestions должен быть объектом. / STextarea: suggestions must be an object.')
249
+ }
250
+ const activeDescendant = props.suggestions.activeDescendant === undefined
251
+ ? undefined
252
+ : validateDomId('STextarea', 'suggestions.activeDescendant', props.suggestions.activeDescendant)
253
+ return {
254
+ controls: validateDomId('STextarea', 'suggestions.controls', props.suggestions.controls),
255
+ activeDescendant,
256
+ autocomplete: validateExactString(
257
+ 'STextarea',
258
+ 'suggestions.autocomplete',
259
+ props.suggestions.autocomplete ?? 'list',
260
+ TEXTAREA_AUTOCOMPLETE,
261
+ ),
262
+ popupRole: validateExactString(
263
+ 'STextarea',
264
+ 'suggestions.popupRole',
265
+ props.suggestions.popupRole ?? 'listbox',
266
+ TEXTAREA_SUGGESTION_POPUP_ROLES,
267
+ ),
268
+ }
269
+ })
154
270
  const resizeContract = computed<STextareaResizeContract>(() => {
155
271
  const autoResize = validateBoolean('STextarea', 'autoResize', props.autoResize)
156
272
  const maxRows = validatedMaxRows.value
157
- if (!autoResize && maxRows !== undefined) {
273
+ const minHeight = validatedMinHeight.value
274
+ const maxHeight = validatedMaxHeight.value
275
+ if (!autoResize && maxRows !== undefined && minHeight === undefined && maxHeight === undefined) {
158
276
  throw new TypeError(
159
277
  `STextarea: maxRows применяется только при autoResize=true; получено autoResize=false, maxRows=${maxRows}. `
160
278
  + `/ STextarea: maxRows applies only when autoResize=true; received autoResize=false, maxRows=${maxRows}.`,
161
279
  )
162
280
  }
281
+ if (!autoResize && (minHeight !== undefined || maxHeight !== undefined)) {
282
+ throw new TypeError(
283
+ 'STextarea: maxRows/minHeight/maxHeight применяются только при autoResize=true. '
284
+ + '/ STextarea: maxRows/minHeight/maxHeight apply only when autoResize=true.',
285
+ )
286
+ }
287
+ if (maxRows !== undefined && maxHeight !== undefined) {
288
+ throw new TypeError(
289
+ 'STextarea: maxRows и maxHeight являются альтернативными ограничителями; укажите один. '
290
+ + '/ STextarea: maxRows and maxHeight are alternative bounds; provide one.',
291
+ )
292
+ }
293
+ if (minHeight !== undefined && maxHeight !== undefined && maxHeight < minHeight) {
294
+ throw new TypeError(
295
+ `STextarea: maxHeight не может быть меньше minHeight; получено minHeight=${minHeight}, maxHeight=${maxHeight}. `
296
+ + `/ STextarea: maxHeight cannot be below minHeight; received minHeight=${minHeight}, maxHeight=${maxHeight}.`,
297
+ )
298
+ }
163
299
  if (maxRows !== undefined && maxRows < validatedRows.value) {
164
300
  throw new TypeError(
165
301
  `STextarea: maxRows не может быть меньше rows; получено rows=${validatedRows.value}, maxRows=${maxRows}. `
@@ -167,8 +303,13 @@ const resizeContract = computed<STextareaResizeContract>(() => {
167
303
  )
168
304
  }
169
305
  if (!autoResize) return { mode: 'fixed', autoResize: false }
170
- if (maxRows === undefined) return { mode: 'content', autoResize: true }
171
- return { mode: 'content', autoResize: true, maxRows }
306
+ return {
307
+ mode: 'content',
308
+ autoResize: true,
309
+ ...(maxRows === undefined ? {} : { maxRows }),
310
+ ...(minHeight === undefined ? {} : { minHeight }),
311
+ ...(maxHeight === undefined ? {} : { maxHeight }),
312
+ }
172
313
  })
173
314
 
174
315
  function resolveFieldBindings(field: {
@@ -185,18 +326,98 @@ function resolveFieldBindings(field: {
185
326
  })
186
327
  }
187
328
 
329
+ function readSelection(target: HTMLTextAreaElement, coordinate: string): STextareaSelection {
330
+ const start = validateSelectionOffset(target.selectionStart, target.value.length, `${coordinate} selectionStart`)
331
+ const end = validateSelectionOffset(target.selectionEnd, target.value.length, `${coordinate} selectionEnd`)
332
+ if (end < start) {
333
+ throw new RangeError(`STextarea: ${coordinate} selectionEnd=${end} is below selectionStart=${start}.`)
334
+ }
335
+ return {
336
+ start,
337
+ end,
338
+ direction: validateExactString(
339
+ 'STextarea',
340
+ `${coordinate} selectionDirection`,
341
+ target.selectionDirection ?? 'none',
342
+ TEXTAREA_SELECTION_DIRECTIONS,
343
+ ),
344
+ }
345
+ }
346
+
188
347
  function emitState(target: HTMLTextAreaElement): void {
348
+ const selection = readSelection(target, 'state-change')
189
349
  emit('state-change', {
190
350
  text: target.value,
191
- cursor: validateSelectionOffset(target.selectionStart, target.value.length, 'state-change cursor'),
351
+ cursor: selection.start,
352
+ selection,
353
+ composing: composing.value,
192
354
  })
193
355
  }
194
356
 
195
357
  function handleInput(event: Event): void {
196
358
  const target = event.target as HTMLTextAreaElement
359
+ const inputType = event instanceof InputEvent ? event.inputType : ''
360
+ if (!applyingProgrammaticReplacement && inputType !== 'historyUndo' && inputType !== 'historyRedo') {
361
+ replacementRedoStack.splice(0)
362
+ }
197
363
  emit('update:modelValue', target.value)
198
364
  emitState(target)
199
- nextTick(resize)
365
+ void nextTick(scheduleResize)
366
+ }
367
+
368
+ function snapshotTextarea(target: HTMLTextAreaElement, coordinate: string): STextareaHistorySnapshot {
369
+ return {
370
+ text: target.value,
371
+ selection: readSelection(target, coordinate),
372
+ }
373
+ }
374
+
375
+ function restoreHistorySnapshot(target: HTMLTextAreaElement, snapshot: STextareaHistorySnapshot): void {
376
+ target.value = snapshot.text
377
+ target.focus()
378
+ target.setSelectionRange(
379
+ snapshot.selection.start,
380
+ snapshot.selection.end,
381
+ snapshot.selection.direction,
382
+ )
383
+ emit('update:modelValue', snapshot.text)
384
+ emit('selection-change', snapshot.selection)
385
+ emitState(target)
386
+ void nextTick(scheduleResize)
387
+ }
388
+
389
+ function matchesHistorySnapshot(target: HTMLTextAreaElement, snapshot: STextareaHistorySnapshot): boolean {
390
+ return target.value === snapshot.text
391
+ }
392
+
393
+ function handleProgrammaticHistory(event: KeyboardEvent, target: HTMLTextAreaElement): boolean {
394
+ if (event.altKey || !(event.metaKey || event.ctrlKey)) return false
395
+ const key = event.key.toLowerCase()
396
+ const undoRequested = key === 'z' && !event.shiftKey
397
+ const redoRequested = (key === 'z' && event.shiftKey) || (key === 'y' && !event.shiftKey)
398
+ if (!undoRequested && !redoRequested) return false
399
+
400
+ const source = undoRequested ? replacementUndoStack : replacementRedoStack
401
+ const entry = source[source.length - 1]
402
+ const expected = undoRequested ? entry?.after : entry?.before
403
+ if (!entry || !expected || !matchesHistorySnapshot(target, expected)) return false
404
+
405
+ event.preventDefault()
406
+ source.pop()
407
+ if (undoRequested) {
408
+ replacementRedoStack.push(entry)
409
+ restoreHistorySnapshot(target, entry.before)
410
+ } else {
411
+ replacementUndoStack.push(entry)
412
+ restoreHistorySnapshot(target, entry.after)
413
+ }
414
+ return true
415
+ }
416
+
417
+ function handleKeydown(event: KeyboardEvent): void {
418
+ emit('keydown', event)
419
+ if (event.defaultPrevented || composing.value || props.disabled || props.readonly) return
420
+ handleProgrammaticHistory(event, event.currentTarget as HTMLTextAreaElement)
200
421
  }
201
422
 
202
423
  function handleKeyup(event: KeyboardEvent): void {
@@ -209,6 +430,31 @@ function handleClick(event: MouseEvent): void {
209
430
  emitState(event.currentTarget as HTMLTextAreaElement)
210
431
  }
211
432
 
433
+ function handleSelectionChange(event: Event): void {
434
+ const target = event.currentTarget as HTMLTextAreaElement
435
+ const selection = readSelection(target, 'selection-change')
436
+ emit('selection-change', selection)
437
+ emitState(target)
438
+ }
439
+
440
+ function handleCompositionStart(event: CompositionEvent): void {
441
+ composing.value = true
442
+ emit('compositionstart', event)
443
+ emitState(event.currentTarget as HTMLTextAreaElement)
444
+ }
445
+
446
+ function handleCompositionUpdate(event: CompositionEvent): void {
447
+ emit('compositionupdate', event)
448
+ emitState(event.currentTarget as HTMLTextAreaElement)
449
+ }
450
+
451
+ function handleCompositionEnd(event: CompositionEvent): void {
452
+ composing.value = false
453
+ emit('compositionend', event)
454
+ emitState(event.currentTarget as HTMLTextAreaElement)
455
+ scheduleResize()
456
+ }
457
+
212
458
  function resize(): void {
213
459
  const textarea = textareaRef.value
214
460
  const contract = resizeContract.value
@@ -218,10 +464,17 @@ function resize(): void {
218
464
  const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.5
219
465
  const paddingTop = Number.parseFloat(style.paddingTop) || 0
220
466
  const paddingBottom = Number.parseFloat(style.paddingBottom) || 0
221
- const minimumHeight = validatedRows.value * lineHeight + paddingTop + paddingBottom
222
- const maximumHeight = contract.maxRows !== undefined
467
+ const rowMinimumHeight = validatedRows.value * lineHeight + paddingTop + paddingBottom
468
+ const minimumHeight = contract.minHeight ?? rowMinimumHeight
469
+ const maximumHeight = contract.maxHeight ?? (contract.maxRows !== undefined
223
470
  ? contract.maxRows * lineHeight + paddingTop + paddingBottom
224
- : 0
471
+ : 0)
472
+ if (maximumHeight > 0 && maximumHeight < minimumHeight) {
473
+ throw new RangeError(
474
+ `STextarea: вычисленный maxHeight=${maximumHeight} меньше minimumHeight=${minimumHeight}. `
475
+ + `/ STextarea: resolved maxHeight=${maximumHeight} is below minimumHeight=${minimumHeight}.`,
476
+ )
477
+ }
225
478
 
226
479
  if (props.disabled || props.modelValue === null || props.modelValue.length === 0) {
227
480
  textarea.style.height = `${minimumHeight}px`
@@ -241,6 +494,14 @@ function resize(): void {
241
494
  textarea.style.overflowY = 'hidden'
242
495
  }
243
496
 
497
+ function scheduleResize(): void {
498
+ if (resizeFrame !== null || resizeContract.value.mode === 'fixed') return
499
+ resizeFrame = window.requestAnimationFrame(() => {
500
+ resizeFrame = null
501
+ resize()
502
+ })
503
+ }
504
+
244
505
  function focus(): void {
245
506
  textareaRef.value?.focus()
246
507
  }
@@ -265,6 +526,40 @@ function getSelectionOffset(): number {
265
526
  return validateSelectionOffset(textarea.selectionStart, textarea.value.length, 'getSelectionOffset')
266
527
  }
267
528
 
529
+ function getSelection(): STextareaSelection {
530
+ return readSelection(requireTextarea('getSelection'), 'getSelection')
531
+ }
532
+
533
+ function validateSelection(selection: STextareaSelection, textLength: number, coordinate: string): STextareaSelection {
534
+ if (selection === null || typeof selection !== 'object' || Array.isArray(selection)) {
535
+ throw new TypeError(`STextarea: ${coordinate} должен быть selection object. / STextarea: ${coordinate} must be a selection object.`)
536
+ }
537
+ const start = validateSelectionOffset(selection.start, textLength, `${coordinate}.start`)
538
+ const end = validateSelectionOffset(selection.end, textLength, `${coordinate}.end`)
539
+ if (end < start) {
540
+ throw new RangeError(`STextarea: ${coordinate}.end=${end} меньше ${coordinate}.start=${start}.`)
541
+ }
542
+ return {
543
+ start,
544
+ end,
545
+ direction: validateExactString(
546
+ 'STextarea',
547
+ `${coordinate}.direction`,
548
+ selection.direction,
549
+ TEXTAREA_SELECTION_DIRECTIONS,
550
+ ),
551
+ }
552
+ }
553
+
554
+ function setSelection(selection: STextareaSelection, focusOwner = true): void {
555
+ const textarea = requireTextarea('setSelection')
556
+ const validated = validateSelection(selection, textarea.value.length, 'setSelection')
557
+ if (focusOwner) textarea.focus()
558
+ textarea.setSelectionRange(validated.start, validated.end, validated.direction)
559
+ emit('selection-change', validated)
560
+ emitState(textarea)
561
+ }
562
+
268
563
  function setSelectionOffset(offset: number): void {
269
564
  const textarea = requireTextarea('setSelectionOffset')
270
565
  const nextOffset = validateSelectionOffset(offset, textarea.value.length, 'setSelectionOffset')
@@ -272,45 +567,104 @@ function setSelectionOffset(offset: number): void {
272
567
  textarea.setSelectionRange(nextOffset, nextOffset)
273
568
  }
274
569
 
570
+ function replaceSelection(text: string, options: STextareaReplaceOptions = {}): STextareaState {
571
+ const textarea = requireTextarea('replaceSelection')
572
+ if (props.disabled || props.readonly) {
573
+ throw new Error('STextarea: replaceSelection недоступен для disabled/readonly поля. / STextarea: replaceSelection is unavailable for a disabled/readonly field.')
574
+ }
575
+ if (composing.value) {
576
+ throw new Error('STextarea: replaceSelection недоступен во время IME composition. / STextarea: replaceSelection is unavailable during IME composition.')
577
+ }
578
+ if (typeof text !== 'string') {
579
+ throw new TypeError('STextarea: replaceSelection text должен быть строкой. / STextarea: replaceSelection text must be a string.')
580
+ }
581
+ const selection = options.selection === undefined
582
+ ? readSelection(textarea, 'replaceSelection')
583
+ : validateSelection(options.selection, textarea.value.length, 'replaceSelection.selection')
584
+ const selectionMode = validateExactString(
585
+ 'STextarea',
586
+ 'replaceSelection.selectionMode',
587
+ options.selectionMode ?? 'end',
588
+ TEXTAREA_SELECTION_MODES,
589
+ )
590
+ if (options.focus ?? true) textarea.focus()
591
+ const before = snapshotTextarea(textarea, 'replaceSelection before')
592
+ textarea.setRangeText(text, selection.start, selection.end, selectionMode)
593
+ const after = snapshotTextarea(textarea, 'replaceSelection after')
594
+ replacementUndoStack.push({ before, after })
595
+ if (replacementUndoStack.length > PROGRAMMATIC_HISTORY_LIMIT) replacementUndoStack.shift()
596
+ replacementRedoStack.splice(0)
597
+ applyingProgrammaticReplacement = true
598
+ try {
599
+ textarea.dispatchEvent(new InputEvent('input', {
600
+ bubbles: true,
601
+ composed: true,
602
+ data: text,
603
+ inputType: 'insertReplacementText',
604
+ }))
605
+ } finally {
606
+ applyingProgrammaticReplacement = false
607
+ }
608
+ emit('selection-change', after.selection)
609
+ const nextSelection = after.selection
610
+ return {
611
+ text: textarea.value,
612
+ cursor: nextSelection.start,
613
+ selection: nextSelection,
614
+ composing: composing.value,
615
+ }
616
+ }
617
+
275
618
  function insertSoftLineBreak(): void {
276
- const textarea = requireTextarea('insertSoftLineBreak')
277
619
  if (props.disabled || props.readonly) return
278
-
279
- const start = validateSelectionOffset(textarea.selectionStart, textarea.value.length, 'insertSoftLineBreak selectionStart')
280
- const end = validateSelectionOffset(textarea.selectionEnd, textarea.value.length, 'insertSoftLineBreak selectionEnd')
281
- if (end < start) {
282
- throw new RangeError(
283
- `STextarea: insertSoftLineBreak selectionEnd=${end} меньше selectionStart=${start}. `
284
- + `/ STextarea: insertSoftLineBreak selectionEnd=${end} is below selectionStart=${start}.`,
285
- )
286
- }
287
- const nextValue = `${textarea.value.slice(0, start)}\n${textarea.value.slice(end)}`
288
- const nextCursor = start + 1
289
-
290
- emit('update:modelValue', nextValue)
291
- nextTick(() => {
292
- const currentTextarea = textareaRef.value
293
- if (!currentTextarea) return
294
- currentTextarea.focus()
295
- currentTextarea.setSelectionRange(nextCursor, nextCursor)
296
- emitState(currentTextarea)
297
- resize()
298
- })
620
+ replaceSelection('\n')
299
621
  }
300
622
 
301
623
  defineExpose({
302
624
  focus,
625
+ getSelection,
303
626
  getSelectionOffset,
304
627
  insertSoftLineBreak,
628
+ replaceSelection,
305
629
  resize,
630
+ setSelection,
306
631
  setSelectionOffset,
307
632
  })
308
633
 
309
- onMounted(resize)
634
+ onMounted(() => {
635
+ resize()
636
+ const textarea = textareaRef.value
637
+ observedResizeOwner = textarea?.parentElement ?? null
638
+ if (typeof ResizeObserver === 'undefined' || !observedResizeOwner) return
639
+ resizeObserver = new ResizeObserver((entries) => {
640
+ const ownerEntry = entries.find((entry) => entry.target === observedResizeOwner)
641
+ if (!ownerEntry) return
642
+ const width = ownerEntry.contentRect.width
643
+ if (width === observedWidth) return
644
+ observedWidth = width
645
+ scheduleResize()
646
+ })
647
+ resizeObserver.observe(observedResizeOwner)
648
+ })
310
649
  watch(
311
- () => [props.modelValue, props.disabled, props.rows, props.maxRows] as const,
312
- () => nextTick(resize),
650
+ () => [props.modelValue, props.disabled, props.rows, props.maxRows, props.minHeight, props.maxHeight] as const,
651
+ () => { void nextTick(scheduleResize) },
313
652
  )
653
+ watch(() => props.modelValue, (modelValue) => {
654
+ const textarea = textareaRef.value
655
+ if (textarea && textarea.value !== (modelValue ?? '')) {
656
+ replacementUndoStack.splice(0)
657
+ replacementRedoStack.splice(0)
658
+ }
659
+ })
660
+ onBeforeUnmount(() => {
661
+ if (resizeFrame !== null) window.cancelAnimationFrame(resizeFrame)
662
+ resizeObserver?.disconnect()
663
+ resizeObserver = null
664
+ observedResizeOwner = null
665
+ replacementUndoStack.splice(0)
666
+ replacementRedoStack.splice(0)
667
+ })
314
668
  </script>
315
669
 
316
670
  <style lang="postcss" src="./STextarea.css"></style>
@@ -38,8 +38,7 @@
38
38
  :capabilities="editorCapabilities"
39
39
  :read-only="validatedReadOnly"
40
40
  :viewport="validatedViewport"
41
- :aria-label="props.ariaLabel"
42
- :aria-labelledby="props.ariaLabelledby"
41
+ v-bind="editorAccessibleName"
43
42
  :placeholder="props.placeholder"
44
43
  :next-selection-pos="props.nextSelectionPos"
45
44
  @update:model-value="emit('update:code', $event)"
@@ -137,6 +136,9 @@ const codeEditor = ref<InstanceType<typeof import('./SCodeEditor.vue')['default'
137
136
  const plainSurface = ref<HTMLElement | null>(null);
138
137
  const AsyncSCodeEditor = defineAsyncComponent(() => import('../../../internal/lazyCodeEditor'));
139
138
  const ownedAttrs = useOwnedAttrs({ component: 'SCodeBlock', owner: 'code block root' });
139
+ const editorAccessibleName = computed<AccessibleName>(() => props.ariaLabel !== undefined
140
+ ? { ariaLabel: props.ariaLabel }
141
+ : { ariaLabelledby: props.ariaLabelledby });
140
142
  const emit = defineEmits<{
141
143
  (e: 'cursor-activity', payload: { pos: number; lineFrom: number; lineTo: number; lineText: string; cursorCol: number }): void
142
144
  (e: 'update:code', value: string): void
@@ -1,4 +1,37 @@
1
1
  /** Ключ элемента виртуального списка. / Virtual-list item key. */
2
2
  export type SVirtualListItemKey = string | number
3
3
 
4
+ /** Выравнивание явного перехода к строке. / Alignment for explicit row navigation. */
5
+ export type SVirtualListAlignment = 'auto' | 'start' | 'center' | 'end'
6
+
7
+ /** Устойчивый viewport anchor: доменный key и смещение внутри строки. */
8
+ export interface SVirtualListAnchor {
9
+ key: SVirtualListItemKey
10
+ offset: number
11
+ }
12
+
13
+ export interface SVirtualListVisibleRange {
14
+ firstIndex: number
15
+ lastIndex: number
16
+ firstKey: SVirtualListItemKey | null
17
+ lastKey: SVirtualListItemKey | null
18
+ }
19
+
20
+ export interface SVirtualListViewportState {
21
+ anchor: SVirtualListAnchor | null
22
+ visibleRange: SVirtualListVisibleRange
23
+ nearStart: boolean
24
+ atEnd: boolean
25
+ pinnedToEnd: boolean
26
+ }
27
+
28
+ /** Сериализуемый снимок позиции для переключения между keyed collections. */
29
+ export interface SVirtualListViewportSnapshot {
30
+ anchor: SVirtualListAnchor | null
31
+ followTail: boolean
32
+ }
33
+
34
+ export type SVirtualListSemanticRole = 'list' | 'log'
35
+ export type SVirtualListLiveMode = 'off' | 'polite' | 'assertive'
36
+
4
37
  export type { SVirtualListProps } from './SVirtualList.vue'