mirror of
https://github.com/enso-org/enso.git
synced 2024-12-24 19:03:27 +03:00
Text input widget (#8873)
Closes #8823 https://github.com/enso-org/enso/assets/6566674/966576ec-6507-401c-98d3-bd71c2ffc6b2 Adds a basic text widget for text literals. ### Important Notes Several known restrictions: - Separators would always be replaced with single quotation marks. All types of separators in Enso are supported though, and they would be correctly escaped if needed. - Logic for widget selection probably needs refinement (works for text literals and `Text` types, but does not work for `Text | Integer`, for example) - **(!)** There is a very annoying issue when the input field suddenly loses focus, closing the editing mode and discarding any changes. Debugging shows that it happens when we receive an engine update (and probably recreate the node component/widget tree (???)). It requires a separate investigation.
This commit is contained in:
parent
081c8c889c
commit
ad6348a12a
@ -80,7 +80,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
|
||||
await expect(page.locator('.dropdownContainer')).toBeVisible()
|
||||
await dropDown.expectVisibleWithOptions(page, ['File 1', 'File 2'])
|
||||
await dropDown.clickOption(page, 'File 2')
|
||||
await expect(pathArg.locator('.WidgetToken')).toHaveText(['"', 'File 2', '"'])
|
||||
await expect(pathArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"File 2"')
|
||||
|
||||
// Change value on `path` (dynamic config)
|
||||
await mockMethodCallInfo(page, 'data', {
|
||||
@ -91,10 +91,10 @@ test('Selection widgets in Data.read node', async ({ page }) => {
|
||||
},
|
||||
notAppliedArguments: [1],
|
||||
})
|
||||
await page.getByText('File 2').click()
|
||||
await page.getByText('path').click()
|
||||
await dropDown.expectVisibleWithOptions(page, ['File 1', 'File 2'])
|
||||
await dropDown.clickOption(page, 'File 1')
|
||||
await expect(pathArg.locator('.WidgetToken')).toHaveText(['"', 'File 1', '"'])
|
||||
await expect(pathArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"File 1"')
|
||||
})
|
||||
|
||||
test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
@ -169,10 +169,8 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Count_Distinct',
|
||||
'"',
|
||||
'column 1',
|
||||
'"',
|
||||
])
|
||||
await expect(columnsArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"column 1"')
|
||||
|
||||
// Add another aggregate
|
||||
await columnsArg.locator('.add-item').click()
|
||||
@ -180,9 +178,6 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Count_Distinct',
|
||||
'"',
|
||||
'column 1',
|
||||
'"',
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Group_By',
|
||||
@ -209,14 +204,8 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
await secondColumnArg.click()
|
||||
await dropDown.expectVisibleWithOptions(page, ['column 1', 'column 2'])
|
||||
await dropDown.clickOption(page, 'column 2')
|
||||
await expect(secondItem.locator('.WidgetToken')).toHaveText([
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Group_By',
|
||||
'"',
|
||||
'column 2',
|
||||
'"',
|
||||
])
|
||||
await expect(secondItem.locator('.WidgetToken')).toHaveText(['Aggregate_Column', '.', 'Group_By'])
|
||||
await expect(secondItem.locator('.EnsoTextInputWidget > input')).toHaveValue('"column 2"')
|
||||
|
||||
// Switch aggregates
|
||||
//TODO[ao] I have no idea how to emulate drag. Simple dragTo does not work (some element seem to capture event).
|
||||
|
@ -311,6 +311,7 @@ const handleClick = useDoubleClick(
|
||||
graphBindingsHandler(e)
|
||||
},
|
||||
() => {
|
||||
if (keyboardBusy()) return false
|
||||
stackNavigator.exitNode()
|
||||
},
|
||||
).handleClick
|
||||
|
48
app/gui2/src/components/GraphEditor/widgets/WidgetText.vue
Normal file
48
app/gui2/src/components/GraphEditor/widgets/WidgetText.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import EnsoTextInputWidget from '@/components/widgets/EnsoTextInputWidget.vue'
|
||||
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type { TokenId } from '@/util/ast/abstract'
|
||||
import { asNot } from '@/util/data/types'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
const graph = useGraphStore()
|
||||
const value = computed({
|
||||
get() {
|
||||
const valueStr = WidgetInput.valueRepr(props.input)
|
||||
return valueStr ?? ''
|
||||
},
|
||||
set(value) {
|
||||
props.onUpdate({
|
||||
edit: graph.astModule.edit(),
|
||||
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) },
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
|
||||
priority: 1001,
|
||||
score: (props) => {
|
||||
if (props.input.value instanceof Ast.TextLiteral) return Score.Perfect
|
||||
if (props.input.dynamicConfig?.kind === 'Text_Input') return Score.Perfect
|
||||
const type = props.input.expectedType
|
||||
if (type === 'Standard.Base.Data.Text') return Score.Good
|
||||
return Score.Mismatch
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EnsoTextInputWidget v-model="value" class="WidgetText r-24" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.WidgetText {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { useEvent } from '@/composables/events'
|
||||
import { getTextWidth } from '@/util/measurement'
|
||||
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
|
||||
import { defineKeybinds } from '@/util/shortcuts'
|
||||
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
|
||||
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
@ -261,9 +261,13 @@ watchPostEffect(() => {
|
||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
||||
const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom))
|
||||
const xLabelTop = computed(() => boxHeight.value + margin.value.bottom - AXIS_LABEL_HEIGHT / 2)
|
||||
const xLabelLeft = computed(() => boxWidth.value / 2 + getTextWidth(axis.value.x?.label) / 2)
|
||||
const xLabelLeft = computed(
|
||||
() => boxWidth.value / 2 + getTextWidthBySizeAndFamily(axis.value.x?.label) / 2,
|
||||
)
|
||||
const yLabelTop = computed(() => -margin.value.left + AXIS_LABEL_HEIGHT)
|
||||
const yLabelLeft = computed(() => -boxHeight.value / 2 + getTextWidth(axis.value.y?.label) / 2)
|
||||
const yLabelLeft = computed(
|
||||
() => -boxHeight.value / 2 + getTextWidthBySizeAndFamily(axis.value.y?.label) / 2,
|
||||
)
|
||||
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
|
@ -2,7 +2,7 @@
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { useEvent } from '@/composables/events'
|
||||
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
||||
import { getTextWidth } from '@/util/measurement'
|
||||
import { getTextWidthBySizeAndFamily } from '@/util/measurement'
|
||||
import { VisualizationContainer, defineKeybinds } from '@/util/visualizationBuiltins'
|
||||
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
@ -222,11 +222,13 @@ const xLabelLeft = computed(
|
||||
() =>
|
||||
margin.value.left +
|
||||
boxWidth.value / 2 -
|
||||
getTextWidth(data.value.axis.x.label, LABEL_FONT_STYLE) / 2,
|
||||
getTextWidthBySizeAndFamily(data.value.axis.x.label, LABEL_FONT_STYLE) / 2,
|
||||
)
|
||||
const xLabelTop = computed(() => boxHeight.value + margin.value.top + 20)
|
||||
const yLabelLeft = computed(
|
||||
() => -boxHeight.value / 2 + getTextWidth(data.value.axis.y.label, LABEL_FONT_STYLE) / 2,
|
||||
() =>
|
||||
-boxHeight.value / 2 +
|
||||
getTextWidthBySizeAndFamily(data.value.axis.y.label, LABEL_FONT_STYLE) / 2,
|
||||
)
|
||||
const yLabelTop = computed(() => -margin.value.left + 15)
|
||||
|
||||
|
134
app/gui2/src/components/widgets/EnsoTextInputWidget.vue
Normal file
134
app/gui2/src/components/widgets/EnsoTextInputWidget.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@/composables/events'
|
||||
import { escape, unescape } from '@/util/ast/abstract'
|
||||
import { blurIfNecessary } from '@/util/autoBlur'
|
||||
import { getTextWidthByFont } from '@/util/measurement'
|
||||
import { computed, ref, watch, type StyleValue } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [modelValue: string] }>()
|
||||
|
||||
// Edited value reflects the `modelValue`, but does not update it until the user defocuses the field.
|
||||
const editedValue = ref(props.modelValue)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
editedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
const inputNode = ref<HTMLInputElement>()
|
||||
const inputSize = useResizeObserver(inputNode)
|
||||
const inputMeasurements = computed(() => {
|
||||
if (inputNode.value == null) return { availableWidth: 0, font: '' }
|
||||
let style = window.getComputedStyle(inputNode.value)
|
||||
let availableWidth =
|
||||
inputSize.value.x - (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight))
|
||||
return { availableWidth, font: style.font }
|
||||
})
|
||||
|
||||
const inputStyle = computed<StyleValue>(() => {
|
||||
if (inputNode.value == null) {
|
||||
return {}
|
||||
}
|
||||
const value = `${editedValue.value}`
|
||||
const measurements = inputMeasurements.value
|
||||
const width = getTextWidthByFont(value, measurements.font)
|
||||
return {
|
||||
width: `${width}px`,
|
||||
}
|
||||
})
|
||||
|
||||
/** To prevent other elements from stealing mouse events (which breaks blur),
|
||||
* we instead setup our own `pointerdown` handler while the input is focused.
|
||||
* Any click outside of the input field causes `blur`.
|
||||
* We don’t want to `useAutoBlur` here, because it would require a separate `pointerdown` handler per input widget.
|
||||
* Instead we setup a single handler for the currently focused widget only, and thus safe performance. */
|
||||
function setupAutoBlur() {
|
||||
const options = { capture: true }
|
||||
function callback(event: MouseEvent) {
|
||||
if (blurIfNecessary(inputNode, event)) {
|
||||
window.removeEventListener('pointerdown', callback, options)
|
||||
}
|
||||
}
|
||||
window.addEventListener('pointerdown', callback, options)
|
||||
}
|
||||
|
||||
const separators = /(^('''|"""|['"]))|(('''|"""|['"])$)/g
|
||||
/** Display the value in a more human-readable form for easier editing. */
|
||||
function prepareForEditing() {
|
||||
editedValue.value = unescape(editedValue.value.replace(separators, ''))
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setupAutoBlur()
|
||||
prepareForEditing()
|
||||
}
|
||||
|
||||
const escapedValue = computed(() => `'${escape(editedValue.value)}'`)
|
||||
|
||||
function blur() {
|
||||
emit('update:modelValue', escapedValue.value)
|
||||
editedValue.value = props.modelValue
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="EnsoTextInputWidget"
|
||||
@pointerdown.stop="() => inputNode?.focus()"
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
>
|
||||
<input
|
||||
ref="inputNode"
|
||||
v-model="editedValue"
|
||||
class="value"
|
||||
:style="inputStyle"
|
||||
@focus="focus"
|
||||
@blur="blur"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.EnsoTextInputWidget {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
background: var(--color-widget);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.value {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background: none;
|
||||
border: none;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
line-height: 171.5%;
|
||||
height: 24px;
|
||||
padding: 0px 4px;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: rgba(255, 255, 255, 15%);
|
||||
}
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { PointerButtonMask, usePointer, useResizeObserver } from '@/composables/events'
|
||||
import { blurIfNecessary } from '@/util/autoBlur'
|
||||
import { getTextWidth } from '@/util/measurement'
|
||||
import { getTextWidthByFont } from '@/util/measurement'
|
||||
import { computed, ref, watch, type StyleValue } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@ -84,9 +84,9 @@ const inputStyle = computed<StyleValue>(() => {
|
||||
const textAfter = value.slice(dotIdx + 1)
|
||||
|
||||
const measurements = inputMeasurements.value
|
||||
const total = getTextWidth(value, measurements.font)
|
||||
const beforeDot = getTextWidth(textBefore, measurements.font)
|
||||
const afterDot = getTextWidth(textAfter, measurements.font)
|
||||
const total = getTextWidthByFont(value, measurements.font)
|
||||
const beforeDot = getTextWidthByFont(textBefore, measurements.font)
|
||||
const afterDot = getTextWidthByFont(textAfter, measurements.font)
|
||||
const blankSpace = Math.max(measurements.availableWidth - total, 0)
|
||||
indent = Math.min(Math.max(-blankSpace, afterDot - beforeDot), blankSpace)
|
||||
}
|
||||
@ -123,7 +123,12 @@ function focus() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="SliderWidget" v-on="dragPointer.events">
|
||||
<div
|
||||
class="NumericInputWidget"
|
||||
v-on="dragPointer.events"
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
>
|
||||
<div v-if="props.limits != null" class="fraction" :style="{ width: sliderWidth }"></div>
|
||||
<input
|
||||
ref="inputNode"
|
||||
@ -137,7 +142,7 @@ function focus() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.SliderWidget {
|
||||
.NumericInputWidget {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
justify-content: space-around;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { assert } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { expect, test } from 'vitest'
|
||||
import { MutableModule, type Identifier } from '../abstract'
|
||||
import { MutableModule, escape, unescape, type Identifier } from '../abstract'
|
||||
|
||||
//const disabledCases = [
|
||||
// ' a',
|
||||
@ -473,3 +473,20 @@ test('Splice', () => {
|
||||
expect(spliced.module).toBe(module)
|
||||
expect(spliced.code()).toBe('foo')
|
||||
})
|
||||
|
||||
test.each([
|
||||
['Hello, World!', 'Hello, World!'],
|
||||
['Hello\t\tWorld!', 'Hello\\t\\tWorld!'],
|
||||
['He\nllo, W\rorld!', 'He\\nllo, W\\rorld!'],
|
||||
['Hello,\vWorld!', 'Hello,\\vWorld!'],
|
||||
['Hello, \\World!', 'Hello, \\World!'],
|
||||
['Hello, `World!`', 'Hello, ``World!``'],
|
||||
["'Hello, World!'", "\\'Hello, World!\\'"],
|
||||
['"Hello, World!"', '\\"Hello, World!\\"'],
|
||||
['Hello, \fWorld!', 'Hello, \\fWorld!'],
|
||||
['Hello, \bWorld!', 'Hello, \\bWorld!'],
|
||||
])('Text literals escaping and unescaping', (original, expectedEscaped) => {
|
||||
const escaped = escape(original)
|
||||
expect(escaped).toBe(expectedEscaped)
|
||||
expect(unescape(escaped)).toBe(original)
|
||||
})
|
||||
|
@ -4,7 +4,7 @@ import { parseEnso } from '@/util/ast'
|
||||
import { Err, Ok, type Result } from '@/util/data/result'
|
||||
import { is_ident_or_operator } from '@/util/ffi'
|
||||
import type { LazyObject } from '@/util/parserSupport'
|
||||
import { unsafeEntries } from '@/util/record'
|
||||
import { swapKeysAndValues, unsafeEntries } from '@/util/record'
|
||||
import * as map from 'lib0/map'
|
||||
import * as random from 'lib0/random'
|
||||
import { reactive } from 'vue'
|
||||
@ -1665,12 +1665,19 @@ const mapping: Record<string, string> = {
|
||||
'`': '``',
|
||||
}
|
||||
|
||||
const reverseMapping = swapKeysAndValues(mapping)
|
||||
|
||||
/** Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
|
||||
* NOT USABLE to insert into raw strings. Does not include quotes. */
|
||||
function escape(string: string) {
|
||||
export function escape(string: string) {
|
||||
return string.replace(/[\0\b\f\n\r\t\v"'`]/g, (match) => mapping[match]!)
|
||||
}
|
||||
|
||||
/** The reverse of `escape`: transform the string into human-readable form, not suitable for interpolation. */
|
||||
export function unescape(string: string) {
|
||||
return string.replace(/\\[0bfnrtv"']|``/g, (match) => reverseMapping[match]!)
|
||||
}
|
||||
|
||||
export class MutableTextLiteral extends TextLiteral implements MutableAst {
|
||||
declare readonly module: MutableModule
|
||||
declare readonly fields: FixedMap<AstFields & TextLiteralFields>
|
||||
|
@ -3,18 +3,22 @@ function getMeasureContext() {
|
||||
return (_measureContext ??= document.createElement('canvas').getContext('2d')!)
|
||||
}
|
||||
|
||||
/** Helper function to get text width to make sure that labels on the x axis do not overlap,
|
||||
* and keeps it readable. */
|
||||
export function getTextWidth(
|
||||
/** Helper function to get text width. Accepts font size and family only. For a more precise control, use `getTextWidthByFont`. */
|
||||
export function getTextWidthBySizeAndFamily(
|
||||
text: string | null | undefined,
|
||||
fontSize = '11.5px',
|
||||
fontFamily = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
) {
|
||||
return getTextWidthByFont(text, `${fontSize} ${fontFamily}`)
|
||||
}
|
||||
|
||||
/** Helper function to get text width. `font` is a CSS font specification as per https://developer.mozilla.org/en-US/docs/Web/CSS/font. */
|
||||
export function getTextWidthByFont(text: string | null | undefined, font: string) {
|
||||
if (text == null) {
|
||||
return 0
|
||||
}
|
||||
const context = getMeasureContext()
|
||||
context.font = `${fontSize} ${fontFamily}`
|
||||
context.font = font
|
||||
const metrics = context.measureText(' ' + text)
|
||||
return metrics.width
|
||||
}
|
||||
|
@ -7,3 +7,15 @@ export function unsafeEntries<K extends PropertyKey, V>(obj: Record<K, V>): [K,
|
||||
export function unsafeKeys<K extends PropertyKey>(obj: Record<K, unknown>): K[] {
|
||||
return Object.keys(obj) as any
|
||||
}
|
||||
|
||||
/** Swap keys and value in a record. */
|
||||
export function swapKeysAndValues<K extends PropertyKey, V extends PropertyKey>(
|
||||
record: Record<K, V>,
|
||||
): Record<V, K> {
|
||||
const swappedRecord: Record<V, K> = {} as Record<V, K>
|
||||
for (const key in record) {
|
||||
const value = record[key]
|
||||
swappedRecord[value] = key as K
|
||||
}
|
||||
return swappedRecord
|
||||
}
|
||||
|
@ -4,13 +4,14 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import CheckboxWidget from '@/components/widgets/CheckboxWidget.vue'
|
||||
import DropdownWidget from '@/components/widgets/DropdownWidget.vue'
|
||||
import EnsoTextInputWidget from '@/components/widgets/EnsoTextInputWidget.vue'
|
||||
import NumericInputWidget from '@/components/widgets/NumericInputWidget.vue'
|
||||
|
||||
// === Checkbox props ===
|
||||
|
||||
const checkboxState = ref(false)
|
||||
|
||||
// === Slider props ===
|
||||
// === Numeric props ===
|
||||
|
||||
const state = ref(0)
|
||||
const min = ref(0)
|
||||
@ -20,6 +21,10 @@ const sliderLimits = computed(() => {
|
||||
return withLimits.value ? { min: min.value, max: max.value } : undefined
|
||||
})
|
||||
|
||||
// === Text props ===
|
||||
|
||||
const text = ref('')
|
||||
|
||||
// === Dropdown props ===
|
||||
|
||||
const color = ref('#357ab9')
|
||||
@ -47,6 +52,13 @@ const values = ref(['address', 'age', 'id', 'language', 'location', 'workplace']
|
||||
<HstNumber v-model="max" title="max" />
|
||||
</template>
|
||||
</Variant>
|
||||
<Variant title="text" :meta="{ customBackground: backgroundColor }">
|
||||
<EnsoTextInputWidget v-model="text" />
|
||||
|
||||
<template #controls>
|
||||
<HstText v-model="text" title="v-model" />
|
||||
</template>
|
||||
</Variant>
|
||||
<Variant title="dropdown">
|
||||
<div style="height: 140px">
|
||||
<div style="position: relative">
|
||||
|
Loading…
Reference in New Issue
Block a user