@pgcorp/ui-kit 0.8.0 → 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.
- package/README.md +174 -0
- package/docs/public-api.md +8 -0
- package/docs/theming.md +17 -0
- package/package.json +10 -5
- package/src/components/layout/SResizeHandle.vue +45 -5
- package/src/components/shared/_internal/SDialogSurface.css +18 -0
- package/src/components/shared/_internal/SDialogSurface.vue +6 -1
- package/src/components/shared/_internal/dialogSurface.ts +7 -1
- package/src/components/shared/containers/SDrawer.css +11 -2
- package/src/components/shared/containers/SDrawer.vue +43 -7
- package/src/components/shared/controls/SInteractiveSurface.css +50 -4
- package/src/components/shared/controls/SInteractiveSurface.vue +44 -30
- package/src/components/shared/controls/STextarea.ts +13 -0
- package/src/components/shared/controls/STextarea.vue +391 -37
- package/src/components/shared/data-display/SActionCard.css +6 -2
- package/src/components/shared/data-display/SActionCard.vue +13 -3
- package/src/components/shared/data-display/SActionListItem.css +7 -8
- package/src/components/shared/data-display/SActionListItem.vue +21 -18
- package/src/components/shared/data-display/SChart.vue +2 -2
- package/src/components/shared/data-display/SChip.css +7 -2
- package/src/components/shared/data-display/SCodeBlock.vue +4 -2
- package/src/components/shared/data-display/SVirtualList.ts +33 -0
- package/src/components/shared/data-display/SVirtualList.vue +321 -36
- package/src/internal/chartGeometry.ts +126 -22
- package/src/styles/tokens.css +3 -0
|
@@ -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="
|
|
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
|
-
| {
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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:
|
|
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(
|
|
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
|
|
222
|
-
const
|
|
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(
|
|
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(
|
|
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>
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
@reference "../../../styles/reference.css";
|
|
2
2
|
|
|
3
|
-
.s-action-card { @apply
|
|
3
|
+
.s-action-card { @apply min-h-0 min-w-0; }
|
|
4
4
|
.s-action-card[data-fill='container'] { @apply h-full w-full; }
|
|
5
5
|
.s-action-card__content { @apply flex min-h-0 min-w-0 w-full flex-1 flex-col; }
|
|
6
|
-
.s-action-card__actions { @apply flex shrink-0 items-center gap-1; }
|
|
6
|
+
.s-action-card__actions { @apply flex min-w-0 max-w-full shrink-0 items-center gap-1; }
|
|
7
|
+
.s-action-card[data-actions-layout='responsive'] .s-action-card__actions,
|
|
8
|
+
.s-action-card[data-actions-layout='stacked'] .s-action-card__actions {
|
|
9
|
+
@apply w-full;
|
|
10
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type { VNodeChild } from 'vue'
|
|
3
|
+
import type { InteractiveSurfaceActionsLayout, InteractiveSurfaceContract, InteractiveSurfaceDensity, InteractiveSurfaceFill, InteractiveSurfacePadding, InteractiveSurfaceRadius, InteractiveSurfaceTone } from '../controls/SInteractiveSurface.vue'
|
|
3
4
|
import { useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
4
5
|
import SInteractiveSurface from '../controls/SInteractiveSurface.vue'
|
|
5
6
|
|
|
@@ -13,6 +14,7 @@ export interface Props {
|
|
|
13
14
|
padding?: InteractiveSurfacePadding
|
|
14
15
|
radius?: InteractiveSurfaceRadius
|
|
15
16
|
fill?: InteractiveSurfaceFill
|
|
17
|
+
actionsLayout?: InteractiveSurfaceActionsLayout
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
withDefaults(defineProps<Props>(), {
|
|
@@ -22,14 +24,19 @@ withDefaults(defineProps<Props>(), {
|
|
|
22
24
|
padding: 'md',
|
|
23
25
|
radius: 'lg',
|
|
24
26
|
fill: 'content',
|
|
27
|
+
actionsLayout: 'inline',
|
|
25
28
|
})
|
|
26
29
|
|
|
27
30
|
const emit = defineEmits<{ activate: [event: MouseEvent] }>()
|
|
31
|
+
defineSlots<{
|
|
32
|
+
default?: () => VNodeChild
|
|
33
|
+
actions?: () => VNodeChild
|
|
34
|
+
}>()
|
|
28
35
|
const ownedAttrs = useOwnedAttrs({ component: 'SActionCard', owner: 'action card root' })
|
|
29
36
|
</script>
|
|
30
37
|
|
|
31
38
|
<template>
|
|
32
|
-
<div v-bind="ownedAttrs.bindings()" class="s-action-card" :data-has-actions="Boolean($slots.actions) || undefined" :data-fill="fill" :data-mode="surface.mode">
|
|
39
|
+
<div v-bind="ownedAttrs.bindings()" class="s-action-card" :data-has-actions="Boolean($slots.actions) || undefined" :data-fill="fill" :data-mode="surface.mode" :data-actions-layout="actionsLayout">
|
|
33
40
|
<SInteractiveSurface
|
|
34
41
|
:surface="surface"
|
|
35
42
|
:selected="selected"
|
|
@@ -38,11 +45,14 @@ const ownedAttrs = useOwnedAttrs({ component: 'SActionCard', owner: 'action card
|
|
|
38
45
|
:padding="padding"
|
|
39
46
|
:radius="radius"
|
|
40
47
|
:fill="fill"
|
|
48
|
+
:actions-layout="actionsLayout"
|
|
41
49
|
@activate="emit('activate', $event)"
|
|
42
50
|
>
|
|
43
51
|
<div class="s-action-card__content"><slot /></div>
|
|
52
|
+
<template v-if="$slots.actions" #actions>
|
|
53
|
+
<div class="s-action-card__actions"><slot name="actions" /></div>
|
|
54
|
+
</template>
|
|
44
55
|
</SInteractiveSurface>
|
|
45
|
-
<div v-if="$slots.actions" class="s-action-card__actions"><slot name="actions" /></div>
|
|
46
56
|
</div>
|
|
47
57
|
</template>
|
|
48
58
|
|