@pgcorp/ui-kit 0.1.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/LICENSE +21 -0
- package/README.md +157 -0
- package/docs/accessibility.md +27 -0
- package/docs/getting-started.md +80 -0
- package/docs/licensing.md +30 -0
- package/docs/public-api.md +39 -0
- package/docs/security.md +21 -0
- package/docs/theming.md +35 -0
- package/docs/versioning.md +23 -0
- package/index.ts +3 -0
- package/package.json +592 -0
- package/src/components/icons/_internal/generatedSputnigUiIconMarkup.ts +971 -0
- package/src/components/icons/sputnigUiIcons.ts +530 -0
- package/src/components/layout/SAppShell.vue +232 -0
- package/src/components/layout/SDockRegion.vue +200 -0
- package/src/components/layout/SResizeHandle.vue +251 -0
- package/src/components/layout/SSideMenuButton.vue +104 -0
- package/src/components/layout/SSideMenuRail.vue +47 -0
- package/src/components/layout/SStack.css +25 -0
- package/src/components/layout/SStack.vue +57 -0
- package/src/components/layout/SStatusBarShell.css +15 -0
- package/src/components/layout/SStatusBarShell.vue +26 -0
- package/src/components/layout/SWorkbenchLayout.vue +53 -0
- package/src/components/layout/SWorkbenchPanelShell.css +161 -0
- package/src/components/layout/SWorkbenchPanelShell.vue +286 -0
- package/src/components/layout/_internal/SAppBrand.css +45 -0
- package/src/components/layout/_internal/SAppBrand.vue +37 -0
- package/src/components/layout/_internal/SAppBrandContent.vue +30 -0
- package/src/components/layout/types.ts +28 -0
- package/src/components/shared/_internal/SDialogSurface.css +127 -0
- package/src/components/shared/_internal/SDialogSurface.vue +117 -0
- package/src/components/shared/_internal/SDisclosureSummary.css +92 -0
- package/src/components/shared/_internal/SDisclosureSummary.vue +121 -0
- package/src/components/shared/_internal/SListboxGroup.css +18 -0
- package/src/components/shared/_internal/SListboxGroup.vue +24 -0
- package/src/components/shared/_internal/SMenuItem.css +112 -0
- package/src/components/shared/_internal/SMenuItem.vue +213 -0
- package/src/components/shared/_internal/SMenuLayer.css +21 -0
- package/src/components/shared/_internal/SMenuLayer.vue +48 -0
- package/src/components/shared/_internal/SMenuSeparator.css +5 -0
- package/src/components/shared/_internal/SMenuSeparator.vue +13 -0
- package/src/components/shared/_internal/SToastItem.css +65 -0
- package/src/components/shared/_internal/SToastItem.vue +58 -0
- package/src/components/shared/_internal/STreeNode.vue +761 -0
- package/src/components/shared/_internal/dialogSurface.ts +105 -0
- package/src/components/shared/_internal/focusTrap.ts +40 -0
- package/src/components/shared/_internal/listboxContext.ts +16 -0
- package/src/components/shared/_internal/markerContract.ts +10 -0
- package/src/components/shared/_internal/menuContext.ts +23 -0
- package/src/components/shared/_internal/selectionContract.ts +239 -0
- package/src/components/shared/_internal/sidebarGroupContext.ts +31 -0
- package/src/components/shared/_internal/tabsContext.ts +57 -0
- package/src/components/shared/_internal/treeRuntime.ts +203 -0
- package/src/components/shared/_internal/useSelectionRoving.ts +162 -0
- package/src/components/shared/codeLanguages.ts +168 -0
- package/src/components/shared/complex/SKanbanCard.css +8 -0
- package/src/components/shared/complex/SKanbanCard.vue +76 -0
- package/src/components/shared/complex/SKanbanLane.vue +82 -0
- package/src/components/shared/complex/STree.css +24 -0
- package/src/components/shared/complex/STree.vue +288 -0
- package/src/components/shared/complex/treeAdapter.ts +210 -0
- package/src/components/shared/complex/types.ts +102 -0
- package/src/components/shared/containers/SCloudChrome.css +103 -0
- package/src/components/shared/containers/SCloudChrome.vue +87 -0
- package/src/components/shared/containers/SCloudChromeEmpty.css +10 -0
- package/src/components/shared/containers/SCloudChromeEmpty.vue +18 -0
- package/src/components/shared/containers/SCloudChromeRow.css +78 -0
- package/src/components/shared/containers/SCloudChromeRow.vue +108 -0
- package/src/components/shared/containers/SCollapsiblePanel.css +57 -0
- package/src/components/shared/containers/SCollapsiblePanel.vue +360 -0
- package/src/components/shared/containers/SDrawer.css +60 -0
- package/src/components/shared/containers/SDrawer.vue +86 -0
- package/src/components/shared/containers/SFormGrid.css +26 -0
- package/src/components/shared/containers/SFormGrid.vue +46 -0
- package/src/components/shared/containers/SFormGridItem.css +18 -0
- package/src/components/shared/containers/SFormGridItem.vue +29 -0
- package/src/components/shared/containers/SLeftSidebar.vue +74 -0
- package/src/components/shared/containers/SModal.css +27 -0
- package/src/components/shared/containers/SModal.vue +105 -0
- package/src/components/shared/containers/SPageHeader.css +66 -0
- package/src/components/shared/containers/SPageHeader.vue +45 -0
- package/src/components/shared/containers/SPanel.css +134 -0
- package/src/components/shared/containers/SPanel.vue +169 -0
- package/src/components/shared/containers/SPopover.css +35 -0
- package/src/components/shared/containers/SPopover.vue +551 -0
- package/src/components/shared/containers/SSidebarGroup.css +12 -0
- package/src/components/shared/containers/SSidebarGroup.vue +443 -0
- package/src/components/shared/containers/SSidebarSection.css +41 -0
- package/src/components/shared/containers/SSidebarSection.vue +268 -0
- package/src/components/shared/containers/disclosureSummary.ts +48 -0
- package/src/components/shared/containers/popover.ts +72 -0
- package/src/components/shared/containers/sidebar.ts +206 -0
- package/src/components/shared/controls/SButton.css +274 -0
- package/src/components/shared/controls/SButton.vue +211 -0
- package/src/components/shared/controls/SCheckbox.css +65 -0
- package/src/components/shared/controls/SCheckbox.vue +130 -0
- package/src/components/shared/controls/SCheckboxGroup.vue +134 -0
- package/src/components/shared/controls/SChoiceCards.css +29 -0
- package/src/components/shared/controls/SChoiceCards.vue +145 -0
- package/src/components/shared/controls/SColorSelect.vue +146 -0
- package/src/components/shared/controls/SComboboxTrigger.css +35 -0
- package/src/components/shared/controls/SComboboxTrigger.vue +122 -0
- package/src/components/shared/controls/SContextMenu.css +11 -0
- package/src/components/shared/controls/SContextMenu.vue +186 -0
- package/src/components/shared/controls/SContextToggleButton.vue +64 -0
- package/src/components/shared/controls/SDragHandle.vue +100 -0
- package/src/components/shared/controls/SDropdownMenu.css +5 -0
- package/src/components/shared/controls/SDropdownMenu.vue +188 -0
- package/src/components/shared/controls/SField.css +79 -0
- package/src/components/shared/controls/SField.vue +89 -0
- package/src/components/shared/controls/SFieldGroup.css +58 -0
- package/src/components/shared/controls/SFieldGroup.vue +117 -0
- package/src/components/shared/controls/SFieldLabel.css +12 -0
- package/src/components/shared/controls/SFieldLabel.vue +51 -0
- package/src/components/shared/controls/SInlineTokenEditor.css +61 -0
- package/src/components/shared/controls/SInlineTokenEditor.vue +1081 -0
- package/src/components/shared/controls/SInputText.css +66 -0
- package/src/components/shared/controls/SInputText.vue +321 -0
- package/src/components/shared/controls/SInteractiveSurface.css +72 -0
- package/src/components/shared/controls/SInteractiveSurface.vue +326 -0
- package/src/components/shared/controls/SLink.css +114 -0
- package/src/components/shared/controls/SLink.ts +85 -0
- package/src/components/shared/controls/SLink.vue +114 -0
- package/src/components/shared/controls/SListbox.css +31 -0
- package/src/components/shared/controls/SListbox.vue +388 -0
- package/src/components/shared/controls/SListboxOption.css +63 -0
- package/src/components/shared/controls/SListboxOption.vue +85 -0
- package/src/components/shared/controls/SMarker.css +19 -0
- package/src/components/shared/controls/SMarker.vue +33 -0
- package/src/components/shared/controls/SMenuSurface.css +20 -0
- package/src/components/shared/controls/SMenuSurface.vue +333 -0
- package/src/components/shared/controls/SSegmentedControl.css +38 -0
- package/src/components/shared/controls/SSegmentedControl.vue +134 -0
- package/src/components/shared/controls/SSelect.css +12 -0
- package/src/components/shared/controls/SSelect.vue +338 -0
- package/src/components/shared/controls/STextarea.css +31 -0
- package/src/components/shared/controls/STextarea.vue +316 -0
- package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +224 -0
- package/src/components/shared/controls/menu.ts +178 -0
- package/src/components/shared/data-display/SActionCard.css +6 -0
- package/src/components/shared/data-display/SActionCard.vue +49 -0
- package/src/components/shared/data-display/SActionList.css +15 -0
- package/src/components/shared/data-display/SActionList.vue +34 -0
- package/src/components/shared/data-display/SActionListItem.css +114 -0
- package/src/components/shared/data-display/SActionListItem.vue +230 -0
- package/src/components/shared/data-display/SBadge.css +73 -0
- package/src/components/shared/data-display/SBadge.vue +52 -0
- package/src/components/shared/data-display/SCodeBlock.css +56 -0
- package/src/components/shared/data-display/SCodeBlock.vue +188 -0
- package/src/components/shared/data-display/SCodeEditor.css +178 -0
- package/src/components/shared/data-display/SCodeEditor.vue +274 -0
- package/src/components/shared/data-display/SCodeSearchPanel.vue +327 -0
- package/src/components/shared/data-display/SCopyField.vue +77 -0
- package/src/components/shared/data-display/SDocBlock.css +136 -0
- package/src/components/shared/data-display/SDocBlock.vue +358 -0
- package/src/components/shared/data-display/SEmptyState.css +42 -0
- package/src/components/shared/data-display/SEmptyState.vue +79 -0
- package/src/components/shared/data-display/SExpandableText.vue +314 -0
- package/src/components/shared/data-display/SJsonTree.css +6 -0
- package/src/components/shared/data-display/SJsonTree.vue +111 -0
- package/src/components/shared/data-display/SKeyValueGrid.css +37 -0
- package/src/components/shared/data-display/SKeyValueGrid.vue +277 -0
- package/src/components/shared/data-display/SLinkedSystemsList.vue +270 -0
- package/src/components/shared/data-display/SMessage.css +88 -0
- package/src/components/shared/data-display/SMessage.vue +152 -0
- package/src/components/shared/data-display/SMetricCard.css +32 -0
- package/src/components/shared/data-display/SMetricCard.vue +117 -0
- package/src/components/shared/data-display/SProgressBar.css +84 -0
- package/src/components/shared/data-display/SProgressBar.vue +114 -0
- package/src/components/shared/data-display/SProgressIndicator.css +59 -0
- package/src/components/shared/data-display/SProgressIndicator.vue +104 -0
- package/src/components/shared/data-display/SSectionHeader.css +84 -0
- package/src/components/shared/data-display/SSectionHeader.vue +58 -0
- package/src/components/shared/data-display/SStatus.css +90 -0
- package/src/components/shared/data-display/SStatus.vue +79 -0
- package/src/components/shared/data-display/STable.css +83 -0
- package/src/components/shared/data-display/STable.vue +339 -0
- package/src/components/shared/data-display/SToastContainer.css +53 -0
- package/src/components/shared/data-display/SToastContainer.vue +31 -0
- package/src/components/shared/data-display/STooltip.css +114 -0
- package/src/components/shared/data-display/STooltip.vue +550 -0
- package/src/components/shared/data-display/STooltipAnchor.css +21 -0
- package/src/components/shared/data-display/STooltipAnchor.vue +119 -0
- package/src/components/shared/data-display/SVirtualList.css +24 -0
- package/src/components/shared/data-display/SVirtualList.ts +19 -0
- package/src/components/shared/data-display/SVirtualList.vue +361 -0
- package/src/components/shared/data-display/json.ts +136 -0
- package/src/components/shared/data-display/table.ts +76 -0
- package/src/components/shared/database/SDataGrid.css +3 -0
- package/src/components/shared/database/SDataGrid.vue +464 -0
- package/src/components/shared/database/SSqlEditor.vue +363 -0
- package/src/components/shared/database/dataGrid.ts +61 -0
- package/src/components/shared/feedback/SAsyncState.css +26 -0
- package/src/components/shared/feedback/SAsyncState.vue +190 -0
- package/src/components/shared/graph/SGraphViewport.ts +1 -0
- package/src/components/shared/graph/SGraphViewport.vue +858 -0
- package/src/components/shared/graph/_internal/graphViewportContract.ts +362 -0
- package/src/components/shared/markdown.ts +169 -0
- package/src/components/shared/navigation/SBottomNav.vue +692 -0
- package/src/components/shared/navigation/SBreadcrumbs.css +26 -0
- package/src/components/shared/navigation/SBreadcrumbs.vue +155 -0
- package/src/components/shared/navigation/SCatalogNavigator.css +100 -0
- package/src/components/shared/navigation/SCatalogNavigator.vue +308 -0
- package/src/components/shared/navigation/STab.vue +363 -0
- package/src/components/shared/navigation/STabList.vue +442 -0
- package/src/components/shared/navigation/STabPanel.vue +165 -0
- package/src/components/shared/navigation/STabPanels.vue +33 -0
- package/src/components/shared/navigation/STabs.vue +617 -0
- package/src/components/shared/navigation/SWizardSteps.css +69 -0
- package/src/components/shared/navigation/SWizardSteps.vue +171 -0
- package/src/components/shared/navigation/_internal/SCatalogNavigatorGroup.vue +63 -0
- package/src/components/shared/persona/SPersonaProjectPicker.css +36 -0
- package/src/components/shared/persona/SPersonaProjectPicker.vue +361 -0
- package/src/components/shared/persona/SPersonaRunWorkbench.css +109 -0
- package/src/components/shared/persona/SPersonaRunWorkbench.vue +549 -0
- package/src/components/shared/persona/_internal/SPersonaRunConsole.css +40 -0
- package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +298 -0
- package/src/components/shared/persona/_internal/inventory.ts +110 -0
- package/src/components/shared/persona/_internal/labels.ts +79 -0
- package/src/components/shared/persona/presentation.ts +371 -0
- package/src/components/shared/persona/types.ts +256 -0
- package/src/composables/uiPreferencesReset.ts +52 -0
- package/src/composables/useClipboard.ts +37 -0
- package/src/composables/useDialogOverlay.ts +85 -0
- package/src/composables/useDisclosure.ts +40 -0
- package/src/composables/useFloatingPosition.ts +245 -0
- package/src/composables/useNotifier.ts +26 -0
- package/src/composables/useSidebarPanelState.ts +610 -0
- package/src/internal/codeEditorContract.ts +110 -0
- package/src/internal/codeEditorExtensions.ts +27 -0
- package/src/internal/feedbackPresentation.ts +136 -0
- package/src/internal/fieldSurface.ts +59 -0
- package/src/internal/focusNavigation.ts +69 -0
- package/src/internal/inlineTokenEditorContract.ts +136 -0
- package/src/internal/interactiveElement.ts +33 -0
- package/src/internal/layerStack.ts +202 -0
- package/src/internal/linkTarget.ts +256 -0
- package/src/internal/ownedAttrs.ts +464 -0
- package/src/internal/passiveContentContract.ts +129 -0
- package/src/internal/pointerInteractionLease.ts +81 -0
- package/src/internal/runtimeContract.ts +194 -0
- package/src/internal/runtimeTheme.ts +159 -0
- package/src/internal/semanticSizing.ts +60 -0
- package/src/internal/useMenuLayer.ts +129 -0
- package/src/stores/useNotifierStore.ts +69 -0
- package/src/styles/reference.css +4 -0
- package/src/styles/style.css +151 -0
- package/src/styles/tokens.css +836 -0
- package/src/theme.ts +449 -0
- package/src/types/highlight-js-lib.d.ts +76 -0
- package/src/variants.ts +15 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<SField
|
|
3
|
+
:control-id="contract.inputId"
|
|
4
|
+
:label="contract.label"
|
|
5
|
+
:required="contract.required"
|
|
6
|
+
:help-text="contract.helpText"
|
|
7
|
+
:error-message="contract.errorMessage"
|
|
8
|
+
:invalid="contract.invalid"
|
|
9
|
+
:disabled="contract.disabled"
|
|
10
|
+
:readonly="contract.readonly"
|
|
11
|
+
>
|
|
12
|
+
<template #default="field">
|
|
13
|
+
<div ref="anchorRef" class="s-select-anchor">
|
|
14
|
+
<SComboboxTrigger
|
|
15
|
+
ref="triggerRef"
|
|
16
|
+
:id="field.controlId"
|
|
17
|
+
:title="contract.title"
|
|
18
|
+
:accessible-label="accessibleLabel"
|
|
19
|
+
:expanded="isOpen"
|
|
20
|
+
:controls="listboxId"
|
|
21
|
+
:display-value="displayValue"
|
|
22
|
+
:placeholder="contract.placeholder"
|
|
23
|
+
:marker="contract.triggerMarker ?? selectedOption?.marker"
|
|
24
|
+
:size="contract.size"
|
|
25
|
+
:disabled="field.disabled"
|
|
26
|
+
:invalid="field.invalid"
|
|
27
|
+
:described-by="mergeIdReferences(selectOwnedAttrs['aria-describedby'], field.describedBy)"
|
|
28
|
+
:required="field.required"
|
|
29
|
+
:readonly="field.readonly"
|
|
30
|
+
:test-id="selectOwnedAttrs['data-testid']"
|
|
31
|
+
:test="selectOwnedAttrs['data-test']"
|
|
32
|
+
:task-mapping-internal-value="selectOwnedAttrs['data-task-mapping-internal-value']"
|
|
33
|
+
:task-mapping-kind="selectOwnedAttrs['data-task-mapping-kind']"
|
|
34
|
+
:appearance="contract.appearance"
|
|
35
|
+
:width="contract.appearance === 'swatch' ? 'auto' : 'full'"
|
|
36
|
+
@toggle="toggle"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
<teleport to="body">
|
|
40
|
+
<div v-if="isOpen" ref="panelRef" :style="floatingStyle" class="s-select-layer">
|
|
41
|
+
<SListbox
|
|
42
|
+
ref="listboxRef"
|
|
43
|
+
v-bind="listboxProps"
|
|
44
|
+
@update:model-value="onListboxUpdate"
|
|
45
|
+
@escape="close('escape')"
|
|
46
|
+
@tab="closeForTab"
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
</teleport>
|
|
50
|
+
</template>
|
|
51
|
+
</SField>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<script lang="ts">
|
|
55
|
+
import type { SelectionKey as PublicSelectionKey } from '../_internal/useSelectionRoving'
|
|
56
|
+
import type {
|
|
57
|
+
SelectionEntry as PublicSelectionEntry,
|
|
58
|
+
SelectionMode as PublicSelectionMode,
|
|
59
|
+
SelectionModelValue as PublicSelectionModelValue,
|
|
60
|
+
SelectionOption as PublicSelectionOption,
|
|
61
|
+
SelectionOptionGroup as PublicSelectionOptionGroup,
|
|
62
|
+
} from '../_internal/selectionContract'
|
|
63
|
+
import type { SMarker as PublicMarker } from '../_internal/markerContract'
|
|
64
|
+
|
|
65
|
+
export type SSelectOption<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOption<TKey>
|
|
66
|
+
export type SSelectOptionGroup<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOptionGroup<TKey>
|
|
67
|
+
export type SSelectEntry<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionEntry<TKey>
|
|
68
|
+
export type SSelectMode = PublicSelectionMode
|
|
69
|
+
export type SSelectModelValue<
|
|
70
|
+
TKey extends PublicSelectionKey,
|
|
71
|
+
TMode extends SSelectMode,
|
|
72
|
+
> = PublicSelectionModelValue<TKey, TMode>
|
|
73
|
+
|
|
74
|
+
interface SharedProps<TKey extends PublicSelectionKey> {
|
|
75
|
+
options: readonly SSelectEntry<TKey>[]
|
|
76
|
+
label?: string
|
|
77
|
+
placeholder?: string
|
|
78
|
+
disabled?: boolean
|
|
79
|
+
readonly?: boolean
|
|
80
|
+
required?: boolean
|
|
81
|
+
invalid?: boolean
|
|
82
|
+
errorMessage?: string
|
|
83
|
+
helpText?: string
|
|
84
|
+
emptyOptionLabel?: string
|
|
85
|
+
id?: string
|
|
86
|
+
size?: 'xs' | 'sm' | 'md'
|
|
87
|
+
appearance?: 'field' | 'swatch'
|
|
88
|
+
align?: 'left' | 'right' | 'center'
|
|
89
|
+
triggerMarker?: PublicMarker
|
|
90
|
+
title?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Props<
|
|
94
|
+
TKey extends PublicSelectionKey = PublicSelectionKey,
|
|
95
|
+
TMode extends SSelectMode = 'single',
|
|
96
|
+
> = SharedProps<TKey> & (TMode extends 'multiple'
|
|
97
|
+
? { selectionMode: 'multiple'; modelValue: readonly TKey[]; emptyOptionLabel?: never }
|
|
98
|
+
: { selectionMode?: 'single'; modelValue: TKey | null; emptyOptionLabel?: string })
|
|
99
|
+
|
|
100
|
+
type ComponentProps<TKey extends PublicSelectionKey> = SharedProps<TKey> & (
|
|
101
|
+
| { selectionMode?: 'single'; modelValue: TKey | null; emptyOptionLabel?: string }
|
|
102
|
+
| { selectionMode: 'multiple'; modelValue: readonly TKey[]; emptyOptionLabel?: never }
|
|
103
|
+
)
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<script setup lang="ts" generic="TKey extends SelectionKey = string">
|
|
107
|
+
import { computed, nextTick, onBeforeUnmount, ref, useId } from 'vue'
|
|
108
|
+
import { useFloatingPosition } from '../../../composables/useFloatingPosition'
|
|
109
|
+
import type { FloatingPlacement } from '../../../composables/useFloatingPosition'
|
|
110
|
+
import { findRelativeTabTarget } from '../../../internal/focusNavigation'
|
|
111
|
+
import { registerLayer, type LayerRegistration } from '../../../internal/layerStack'
|
|
112
|
+
import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
113
|
+
import {
|
|
114
|
+
validateBoolean,
|
|
115
|
+
validateDomId,
|
|
116
|
+
validateExactString,
|
|
117
|
+
validateNonEmptyString,
|
|
118
|
+
} from '../../../internal/runtimeContract'
|
|
119
|
+
import {
|
|
120
|
+
isSelectionModelList,
|
|
121
|
+
resolveSelectionInventory,
|
|
122
|
+
validateOptionalSelectionString,
|
|
123
|
+
validateSelectionMarker,
|
|
124
|
+
validateSelectionModel,
|
|
125
|
+
type SelectionMode,
|
|
126
|
+
} from '../_internal/selectionContract'
|
|
127
|
+
import { type SelectionKey } from '../_internal/useSelectionRoving'
|
|
128
|
+
import SComboboxTrigger from './SComboboxTrigger.vue'
|
|
129
|
+
import type { SComboboxTriggerApi } from './SComboboxTrigger.vue'
|
|
130
|
+
import SField from './SField.vue'
|
|
131
|
+
import SListbox from './SListbox.vue'
|
|
132
|
+
import type { SListboxProps, SListboxVariant } from './SListbox.vue'
|
|
133
|
+
|
|
134
|
+
defineOptions({ inheritAttrs: false })
|
|
135
|
+
|
|
136
|
+
const props = withDefaults(defineProps<ComponentProps<TKey>>(), {
|
|
137
|
+
label: undefined,
|
|
138
|
+
placeholder: 'Выберите значение',
|
|
139
|
+
disabled: false,
|
|
140
|
+
readonly: false,
|
|
141
|
+
required: false,
|
|
142
|
+
invalid: false,
|
|
143
|
+
errorMessage: undefined,
|
|
144
|
+
helpText: undefined,
|
|
145
|
+
emptyOptionLabel: undefined,
|
|
146
|
+
id: undefined,
|
|
147
|
+
size: 'md',
|
|
148
|
+
appearance: 'field',
|
|
149
|
+
align: 'left',
|
|
150
|
+
triggerMarker: undefined,
|
|
151
|
+
title: undefined,
|
|
152
|
+
selectionMode: undefined,
|
|
153
|
+
})
|
|
154
|
+
const emit = defineEmits<{
|
|
155
|
+
'update:modelValue': [value: TKey | null] | [value: readonly TKey[]]
|
|
156
|
+
}>()
|
|
157
|
+
const ownedAttrs = useOwnedAttrs({ component: 'SSelect', owner: 'combobox trigger' })
|
|
158
|
+
const selectOwnedAttrs = computed(() => ownedAttrs.bindings())
|
|
159
|
+
const generatedInputId = useId()
|
|
160
|
+
|
|
161
|
+
const contract = computed(() => {
|
|
162
|
+
const selectionMode: SelectionMode = validateExactString(
|
|
163
|
+
'SSelect', 'selectionMode', props.selectionMode ?? 'single', ['single', 'multiple'] as const,
|
|
164
|
+
)
|
|
165
|
+
let emptyOptionLabel: string | undefined
|
|
166
|
+
if (props.emptyOptionLabel !== undefined) {
|
|
167
|
+
if (selectionMode === 'multiple') {
|
|
168
|
+
throw new Error('SSelect: emptyOptionLabel is available only in single selection mode')
|
|
169
|
+
}
|
|
170
|
+
emptyOptionLabel = validateNonEmptyString('SSelect', 'emptyOptionLabel', props.emptyOptionLabel)
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
inputId: props.id === undefined
|
|
174
|
+
? `s-select-${generatedInputId}`
|
|
175
|
+
: validateDomId('SSelect', 'id', props.id),
|
|
176
|
+
label: props.label === undefined
|
|
177
|
+
? undefined
|
|
178
|
+
: validateNonEmptyString('SSelect', 'label', props.label),
|
|
179
|
+
placeholder: validateNonEmptyString('SSelect', 'placeholder', props.placeholder),
|
|
180
|
+
disabled: validateBoolean('SSelect', 'disabled', props.disabled),
|
|
181
|
+
readonly: validateBoolean('SSelect', 'readonly', props.readonly),
|
|
182
|
+
required: validateBoolean('SSelect', 'required', props.required),
|
|
183
|
+
invalid: validateBoolean('SSelect', 'invalid', props.invalid),
|
|
184
|
+
errorMessage: validateOptionalSelectionString('SSelect', 'errorMessage', props.errorMessage),
|
|
185
|
+
helpText: validateOptionalSelectionString('SSelect', 'helpText', props.helpText),
|
|
186
|
+
size: validateExactString('SSelect', 'size', props.size, ['xs', 'sm', 'md'] as const),
|
|
187
|
+
appearance: validateExactString(
|
|
188
|
+
'SSelect', 'appearance', props.appearance, ['field', 'swatch'] as const,
|
|
189
|
+
),
|
|
190
|
+
align: validateExactString(
|
|
191
|
+
'SSelect', 'align', props.align, ['left', 'right', 'center'] as const,
|
|
192
|
+
),
|
|
193
|
+
triggerMarker: validateSelectionMarker('SSelect', 'triggerMarker', props.triggerMarker),
|
|
194
|
+
title: validateOptionalSelectionString('SSelect', 'title', props.title),
|
|
195
|
+
selectionMode,
|
|
196
|
+
emptyOptionLabel,
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
const listboxId = computed(() => `${contract.value.inputId}-listbox`)
|
|
200
|
+
const resolvedSelectionMode = computed(() => contract.value.selectionMode)
|
|
201
|
+
const inventory = computed(() => resolveSelectionInventory<TKey>('SSelect', props.options))
|
|
202
|
+
const selectedTokens = computed(() => validateSelectionModel(
|
|
203
|
+
'SSelect', contract.value.selectionMode, props.modelValue, inventory.value,
|
|
204
|
+
))
|
|
205
|
+
const accessibleLabel = computed(() => {
|
|
206
|
+
const ownedLabel = selectOwnedAttrs.value['aria-label']
|
|
207
|
+
if (ownedLabel !== undefined) return validateNonEmptyString('SSelect', 'aria-label', ownedLabel)
|
|
208
|
+
return contract.value.label ?? contract.value.placeholder
|
|
209
|
+
})
|
|
210
|
+
const selectedOption = computed(() => inventory.value.options.find((record) => (
|
|
211
|
+
selectedTokens.value.has(record.token)
|
|
212
|
+
))?.option ?? null)
|
|
213
|
+
const displayValue = computed(() => inventory.value.options
|
|
214
|
+
.filter((record) => selectedTokens.value.has(record.token))
|
|
215
|
+
.map((record) => record.option.label)
|
|
216
|
+
.join(', '))
|
|
217
|
+
const resolvedEmptyOptionLabel = computed(() => contract.value.emptyOptionLabel)
|
|
218
|
+
const listboxProps = computed<
|
|
219
|
+
SListboxProps<TKey, 'single'> | SListboxProps<TKey, 'multiple'>
|
|
220
|
+
>(() => {
|
|
221
|
+
const variant: SListboxVariant = contract.value.appearance === 'swatch' ? 'color' : 'select'
|
|
222
|
+
const shared = {
|
|
223
|
+
id: listboxId.value,
|
|
224
|
+
label: accessibleLabel.value,
|
|
225
|
+
options: props.options,
|
|
226
|
+
variant,
|
|
227
|
+
invalid: contract.value.invalid,
|
|
228
|
+
}
|
|
229
|
+
if (contract.value.selectionMode === 'multiple') {
|
|
230
|
+
if (!isSelectionModelList(props.modelValue)) {
|
|
231
|
+
throw new TypeError('SSelect: multiple selection requires modelValue to be an array')
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
...shared,
|
|
235
|
+
selectionMode: 'multiple',
|
|
236
|
+
modelValue: props.modelValue,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (isSelectionModelList(props.modelValue)) {
|
|
240
|
+
throw new TypeError('SSelect: single selection requires modelValue to be a selection key or null')
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
...shared,
|
|
244
|
+
selectionMode: 'single',
|
|
245
|
+
modelValue: props.modelValue,
|
|
246
|
+
clearOptionLabel: contract.value.emptyOptionLabel,
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const isOpen = ref(false)
|
|
251
|
+
const layerZIndex = ref('var(--s-layer-floating)')
|
|
252
|
+
let layerRegistration: LayerRegistration | null = null
|
|
253
|
+
const triggerRef = ref<SComboboxTriggerApi | null>(null)
|
|
254
|
+
const listboxRef = ref<{ focusSelectedOrFirst: () => void } | null>(null)
|
|
255
|
+
const placement = computed<FloatingPlacement>(() => (
|
|
256
|
+
`bottom-${contract.value.align === 'left' ? 'start' : contract.value.align === 'right' ? 'end' : 'center'}`
|
|
257
|
+
))
|
|
258
|
+
const { anchorRef, panelRef, floatingStyle } = useFloatingPosition({
|
|
259
|
+
active: isOpen,
|
|
260
|
+
placement: () => placement.value,
|
|
261
|
+
widthMatch: () => contract.value.appearance === 'field' ? 'exact' : 'none',
|
|
262
|
+
zIndex: () => layerZIndex.value,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
type CloseReason = 'trigger' | 'selection' | 'escape' | 'tab' | 'outside'
|
|
266
|
+
|
|
267
|
+
function close(reason: CloseReason = 'trigger'): void {
|
|
268
|
+
if (!isOpen.value) return
|
|
269
|
+
isOpen.value = false
|
|
270
|
+
layerRegistration?.unregister({
|
|
271
|
+
restoreFocus: reason === 'trigger' || reason === 'selection' || reason === 'escape',
|
|
272
|
+
})
|
|
273
|
+
layerRegistration = null
|
|
274
|
+
layerZIndex.value = 'var(--s-layer-floating)'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function triggerElement(): HTMLElement {
|
|
278
|
+
const trigger = triggerRef.value
|
|
279
|
+
if (!trigger) throw new Error('SSelect: combobox trigger API is unavailable')
|
|
280
|
+
return trigger.getElement()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function closeForTab(event: KeyboardEvent): void {
|
|
284
|
+
const trigger = triggerElement()
|
|
285
|
+
const scope = layerRegistration?.focusScope() ?? document
|
|
286
|
+
const target = findRelativeTabTarget(trigger, scope, panelRef.value, event.shiftKey)
|
|
287
|
+
event.preventDefault()
|
|
288
|
+
close('tab')
|
|
289
|
+
if (target) nextTick(() => target.focus())
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function open(): Promise<void> {
|
|
293
|
+
void selectedTokens.value
|
|
294
|
+
void resolvedEmptyOptionLabel.value
|
|
295
|
+
if (contract.value.disabled || contract.value.readonly || isOpen.value) return
|
|
296
|
+
const trigger = triggerElement()
|
|
297
|
+
isOpen.value = true
|
|
298
|
+
layerRegistration = registerLayer({
|
|
299
|
+
kind: 'listbox',
|
|
300
|
+
root: () => panelRef.value,
|
|
301
|
+
anchor: () => anchorRef.value,
|
|
302
|
+
restoreTarget: trigger,
|
|
303
|
+
requestDismiss: (reason) => close(reason === 'escape' ? 'escape' : 'outside'),
|
|
304
|
+
})
|
|
305
|
+
layerZIndex.value = layerRegistration.zIndex
|
|
306
|
+
await nextTick()
|
|
307
|
+
if (isOpen.value) listboxRef.value?.focusSelectedOrFirst()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function toggle(): void {
|
|
311
|
+
if (isOpen.value) close('trigger')
|
|
312
|
+
else void open()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function onListboxUpdate(value: TKey | null | readonly TKey[]): void {
|
|
316
|
+
if (resolvedSelectionMode.value === 'multiple') {
|
|
317
|
+
if (!isSelectionModelList(value)) {
|
|
318
|
+
throw new TypeError('SSelect: multiple selection update must be an array')
|
|
319
|
+
}
|
|
320
|
+
validateSelectionModel('SSelect update', 'multiple', value, inventory.value)
|
|
321
|
+
emit('update:modelValue', value)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
if (isSelectionModelList(value)) {
|
|
325
|
+
throw new TypeError('SSelect: single selection update must be a selection key or null')
|
|
326
|
+
}
|
|
327
|
+
validateSelectionModel('SSelect update', 'single', value, inventory.value)
|
|
328
|
+
emit('update:modelValue', value)
|
|
329
|
+
close('selection')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
onBeforeUnmount(() => {
|
|
333
|
+
layerRegistration?.unregister({ restoreFocus: false })
|
|
334
|
+
layerRegistration = null
|
|
335
|
+
})
|
|
336
|
+
</script>
|
|
337
|
+
|
|
338
|
+
<style lang="postcss" src="./SSelect.css" scoped></style>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@reference "../../../styles/reference.css";
|
|
2
|
+
|
|
3
|
+
.s-textarea {
|
|
4
|
+
@apply block w-full resize-none text-sm;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.s-textarea[data-monospace='true'] {
|
|
8
|
+
@apply font-mono;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.s-textarea[data-appearance='default'][data-size='sm'] { @apply px-2.5 py-1.5 text-xs; }
|
|
12
|
+
.s-textarea[data-appearance='default'][data-size='md'] { @apply px-3 py-2 text-sm; }
|
|
13
|
+
.s-textarea[data-appearance='default'][data-size='lg'] { @apply px-4 py-2.5 text-lg; }
|
|
14
|
+
|
|
15
|
+
.s-textarea[data-appearance='ghost'] {
|
|
16
|
+
@apply border-0 bg-transparent p-0 text-surface-800 shadow-none ring-0 placeholder:text-surface-400 dark:text-surface-100 dark:placeholder:text-surface-500;
|
|
17
|
+
appearance: none;
|
|
18
|
+
line-height: 1.45;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.s-textarea[data-appearance='ghost']:focus {
|
|
22
|
+
@apply border-0 shadow-none ring-0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.s-textarea[data-appearance='ghost']:disabled {
|
|
26
|
+
@apply cursor-not-allowed text-surface-400 dark:text-surface-500;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.s-textarea[data-appearance='ghost'][data-invalid='true'] {
|
|
30
|
+
@apply text-danger-700 placeholder:text-danger-400 dark:text-danger-300 dark:placeholder:text-danger-300/80;
|
|
31
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<SField
|
|
3
|
+
:control-id="textareaId"
|
|
4
|
+
:label="label"
|
|
5
|
+
:required="required"
|
|
6
|
+
:help-text="helpText"
|
|
7
|
+
:error-message="errorMessage"
|
|
8
|
+
:invalid="invalid"
|
|
9
|
+
:disabled="disabled"
|
|
10
|
+
:readonly="readonly"
|
|
11
|
+
:fill="validatedAppearance === 'ghost' ? 'container' : 'content'"
|
|
12
|
+
>
|
|
13
|
+
<template #default="field">
|
|
14
|
+
<textarea
|
|
15
|
+
v-bind="{ ...ownedAttrs.bindings(), ...resolveFieldBindings(field) }"
|
|
16
|
+
:id="textareaId"
|
|
17
|
+
ref="textareaRef"
|
|
18
|
+
class="s-textarea"
|
|
19
|
+
:data-appearance="validatedAppearance"
|
|
20
|
+
:data-size="validatedSize"
|
|
21
|
+
:data-monospace="monospace || undefined"
|
|
22
|
+
:data-auto-resize="resizeContract.mode === 'content' || undefined"
|
|
23
|
+
:data-max-rows="validatedMaxRows"
|
|
24
|
+
:data-invalid="field.invalid || undefined"
|
|
25
|
+
:value="modelValue"
|
|
26
|
+
:placeholder="placeholder"
|
|
27
|
+
:disabled="field.disabled"
|
|
28
|
+
:readonly="field.readonly"
|
|
29
|
+
:required="field.required"
|
|
30
|
+
:aria-invalid="field.invalid || undefined"
|
|
31
|
+
:aria-required="field.required || undefined"
|
|
32
|
+
:aria-readonly="field.readonly || undefined"
|
|
33
|
+
:aria-describedby="mergeIdReferences(ownedAttrs.string('aria-describedby'), field.describedBy)"
|
|
34
|
+
:rows="validatedRows"
|
|
35
|
+
@input="handleInput"
|
|
36
|
+
@keydown="emit('keydown', $event)"
|
|
37
|
+
@keyup="handleKeyup"
|
|
38
|
+
@click="handleClick"
|
|
39
|
+
/>
|
|
40
|
+
</template>
|
|
41
|
+
</SField>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
|
|
46
|
+
import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
47
|
+
import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
|
|
48
|
+
import { resolveFieldSurfaceBindings, type FieldSurfaceBindings } from '../../../internal/fieldSurface'
|
|
49
|
+
import {
|
|
50
|
+
resolveOptionalDomId,
|
|
51
|
+
validateBoolean,
|
|
52
|
+
validateBoundedInteger,
|
|
53
|
+
validateExactString,
|
|
54
|
+
} from '../../../internal/runtimeContract'
|
|
55
|
+
import SField from './SField.vue'
|
|
56
|
+
|
|
57
|
+
defineOptions({
|
|
58
|
+
inheritAttrs: false,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** Визуальная интеграция native textarea с SField. / Native textarea visual integration with SField. */
|
|
62
|
+
export type STextareaAppearance = 'default' | 'ghost'
|
|
63
|
+
export type STextareaSize = 'sm' | 'md' | 'lg'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Дискриминированный resize contract: fixed не имеет maxRows, content может быть bounded или unbounded.
|
|
67
|
+
* Discriminated resize contract: fixed has no maxRows; content can be bounded or unbounded.
|
|
68
|
+
*/
|
|
69
|
+
export type STextareaResizeContract =
|
|
70
|
+
| { mode: 'fixed'; autoResize: false }
|
|
71
|
+
| { mode: 'content'; autoResize: true; maxRows?: number }
|
|
72
|
+
|
|
73
|
+
const TEXTAREA_APPEARANCES = ['default', 'ghost'] as const satisfies readonly STextareaAppearance[]
|
|
74
|
+
const TEXTAREA_SIZES = ['sm', 'md', 'lg'] as const satisfies readonly STextareaSize[]
|
|
75
|
+
const TEXTAREA_ROWS_DOMAIN = { minimum: 1, maximum: 100 } as const
|
|
76
|
+
|
|
77
|
+
export interface Props {
|
|
78
|
+
modelValue: string | null
|
|
79
|
+
label?: string
|
|
80
|
+
placeholder?: string
|
|
81
|
+
disabled?: boolean
|
|
82
|
+
readonly?: boolean
|
|
83
|
+
required?: boolean
|
|
84
|
+
invalid?: boolean
|
|
85
|
+
errorMessage?: string
|
|
86
|
+
helpText?: string
|
|
87
|
+
id?: string
|
|
88
|
+
rows?: number
|
|
89
|
+
size?: STextareaSize
|
|
90
|
+
autoResize?: boolean
|
|
91
|
+
maxRows?: number
|
|
92
|
+
appearance?: STextareaAppearance
|
|
93
|
+
monospace?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
97
|
+
modelValue: '',
|
|
98
|
+
label: undefined,
|
|
99
|
+
placeholder: undefined,
|
|
100
|
+
disabled: false,
|
|
101
|
+
readonly: false,
|
|
102
|
+
required: false,
|
|
103
|
+
invalid: false,
|
|
104
|
+
errorMessage: undefined,
|
|
105
|
+
helpText: undefined,
|
|
106
|
+
id: undefined,
|
|
107
|
+
rows: 3,
|
|
108
|
+
size: 'md',
|
|
109
|
+
autoResize: false,
|
|
110
|
+
maxRows: undefined,
|
|
111
|
+
appearance: 'default',
|
|
112
|
+
monospace: false,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const emit = defineEmits<{
|
|
116
|
+
(event: 'update:modelValue', value: string): void
|
|
117
|
+
(event: 'keydown', keyboardEvent: KeyboardEvent): void
|
|
118
|
+
(event: 'keyup', keyboardEvent: KeyboardEvent): void
|
|
119
|
+
(event: 'click', mouseEvent: MouseEvent): void
|
|
120
|
+
(event: 'state-change', state: { text: string; cursor: number }): void
|
|
121
|
+
}>()
|
|
122
|
+
|
|
123
|
+
const ownedAttrs = useOwnedAttrs({ component: 'STextarea', owner: 'native textarea' })
|
|
124
|
+
useInteractiveLeafRegistration({ owner: 'STextarea' })
|
|
125
|
+
const generatedTextareaId = useId()
|
|
126
|
+
const textareaId = computed(() => resolveOptionalDomId(
|
|
127
|
+
'STextarea',
|
|
128
|
+
'id',
|
|
129
|
+
props.id,
|
|
130
|
+
`s-textarea-${generatedTextareaId}`,
|
|
131
|
+
))
|
|
132
|
+
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
|
133
|
+
const validatedAppearance = computed<STextareaAppearance>(() => validateExactString(
|
|
134
|
+
'STextarea',
|
|
135
|
+
'appearance',
|
|
136
|
+
props.appearance,
|
|
137
|
+
TEXTAREA_APPEARANCES,
|
|
138
|
+
))
|
|
139
|
+
const validatedSize = computed<STextareaSize>(() => validateExactString(
|
|
140
|
+
'STextarea',
|
|
141
|
+
'size',
|
|
142
|
+
props.size,
|
|
143
|
+
TEXTAREA_SIZES,
|
|
144
|
+
))
|
|
145
|
+
const validatedRows = computed(() => validateBoundedInteger(
|
|
146
|
+
'STextarea',
|
|
147
|
+
'rows',
|
|
148
|
+
props.rows,
|
|
149
|
+
TEXTAREA_ROWS_DOMAIN,
|
|
150
|
+
))
|
|
151
|
+
const validatedMaxRows = computed(() => props.maxRows === undefined
|
|
152
|
+
? undefined
|
|
153
|
+
: validateBoundedInteger('STextarea', 'maxRows', props.maxRows, TEXTAREA_ROWS_DOMAIN))
|
|
154
|
+
const resizeContract = computed<STextareaResizeContract>(() => {
|
|
155
|
+
const autoResize = validateBoolean('STextarea', 'autoResize', props.autoResize)
|
|
156
|
+
const maxRows = validatedMaxRows.value
|
|
157
|
+
if (!autoResize && maxRows !== undefined) {
|
|
158
|
+
throw new TypeError(
|
|
159
|
+
`STextarea: maxRows применяется только при autoResize=true; получено autoResize=false, maxRows=${maxRows}. `
|
|
160
|
+
+ `/ STextarea: maxRows applies only when autoResize=true; received autoResize=false, maxRows=${maxRows}.`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
if (maxRows !== undefined && maxRows < validatedRows.value) {
|
|
164
|
+
throw new TypeError(
|
|
165
|
+
`STextarea: maxRows не может быть меньше rows; получено rows=${validatedRows.value}, maxRows=${maxRows}. `
|
|
166
|
+
+ `/ STextarea: maxRows cannot be less than rows; received rows=${validatedRows.value}, maxRows=${maxRows}.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (!autoResize) return { mode: 'fixed', autoResize: false }
|
|
170
|
+
if (maxRows === undefined) return { mode: 'content', autoResize: true }
|
|
171
|
+
return { mode: 'content', autoResize: true, maxRows }
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
function resolveFieldBindings(field: {
|
|
175
|
+
invalid: boolean
|
|
176
|
+
disabled: boolean
|
|
177
|
+
readonly: boolean
|
|
178
|
+
}): FieldSurfaceBindings {
|
|
179
|
+
return resolveFieldSurfaceBindings('STextarea', {
|
|
180
|
+
kind: 'textarea',
|
|
181
|
+
appearance: validatedAppearance.value === 'default' ? 'field' : 'ghost',
|
|
182
|
+
invalid: field.invalid,
|
|
183
|
+
disabled: field.disabled,
|
|
184
|
+
readonly: field.readonly,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function emitState(target: HTMLTextAreaElement): void {
|
|
189
|
+
emit('state-change', {
|
|
190
|
+
text: target.value,
|
|
191
|
+
cursor: validateSelectionOffset(target.selectionStart, target.value.length, 'state-change cursor'),
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function handleInput(event: Event): void {
|
|
196
|
+
const target = event.target as HTMLTextAreaElement
|
|
197
|
+
emit('update:modelValue', target.value)
|
|
198
|
+
emitState(target)
|
|
199
|
+
nextTick(resize)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function handleKeyup(event: KeyboardEvent): void {
|
|
203
|
+
emit('keyup', event)
|
|
204
|
+
emitState(event.currentTarget as HTMLTextAreaElement)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function handleClick(event: MouseEvent): void {
|
|
208
|
+
emit('click', event)
|
|
209
|
+
emitState(event.currentTarget as HTMLTextAreaElement)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resize(): void {
|
|
213
|
+
const textarea = textareaRef.value
|
|
214
|
+
const contract = resizeContract.value
|
|
215
|
+
if (contract.mode === 'fixed' || !textarea || textarea.scrollHeight === 0) return
|
|
216
|
+
|
|
217
|
+
const style = window.getComputedStyle(textarea)
|
|
218
|
+
const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.5
|
|
219
|
+
const paddingTop = Number.parseFloat(style.paddingTop) || 0
|
|
220
|
+
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0
|
|
221
|
+
const minimumHeight = validatedRows.value * lineHeight + paddingTop + paddingBottom
|
|
222
|
+
const maximumHeight = contract.maxRows !== undefined
|
|
223
|
+
? contract.maxRows * lineHeight + paddingTop + paddingBottom
|
|
224
|
+
: 0
|
|
225
|
+
|
|
226
|
+
if (props.disabled || props.modelValue === null || props.modelValue.length === 0) {
|
|
227
|
+
textarea.style.height = `${minimumHeight}px`
|
|
228
|
+
textarea.style.overflowY = 'hidden'
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
textarea.style.height = 'auto'
|
|
233
|
+
const contentHeight = textarea.scrollHeight
|
|
234
|
+
if (maximumHeight > 0 && contentHeight > maximumHeight) {
|
|
235
|
+
textarea.style.height = `${maximumHeight}px`
|
|
236
|
+
textarea.style.overflowY = 'auto'
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
textarea.style.height = `${Math.max(minimumHeight, contentHeight)}px`
|
|
241
|
+
textarea.style.overflowY = 'hidden'
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function focus(): void {
|
|
245
|
+
textareaRef.value?.focus()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function requireTextarea(coordinate: string): HTMLTextAreaElement {
|
|
249
|
+
const textarea = textareaRef.value
|
|
250
|
+
if (!textarea) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`STextarea: textarea не готова для ${coordinate}; дождитесь mount. `
|
|
253
|
+
+ `/ STextarea: textarea is not ready for ${coordinate}; wait for mount.`,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
return textarea
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateSelectionOffset(offset: unknown, textLength: number, coordinate: string): number {
|
|
260
|
+
return validateBoundedInteger('STextarea', coordinate, offset, { minimum: 0, maximum: textLength })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getSelectionOffset(): number {
|
|
264
|
+
const textarea = requireTextarea('getSelectionOffset')
|
|
265
|
+
return validateSelectionOffset(textarea.selectionStart, textarea.value.length, 'getSelectionOffset')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function setSelectionOffset(offset: number): void {
|
|
269
|
+
const textarea = requireTextarea('setSelectionOffset')
|
|
270
|
+
const nextOffset = validateSelectionOffset(offset, textarea.value.length, 'setSelectionOffset')
|
|
271
|
+
textarea.focus()
|
|
272
|
+
textarea.setSelectionRange(nextOffset, nextOffset)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function insertSoftLineBreak(): void {
|
|
276
|
+
const textarea = requireTextarea('insertSoftLineBreak')
|
|
277
|
+
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
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
defineExpose({
|
|
302
|
+
focus,
|
|
303
|
+
getSelectionOffset,
|
|
304
|
+
insertSoftLineBreak,
|
|
305
|
+
resize,
|
|
306
|
+
setSelectionOffset,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
onMounted(resize)
|
|
310
|
+
watch(
|
|
311
|
+
() => [props.modelValue, props.disabled, props.rows, props.maxRows] as const,
|
|
312
|
+
() => nextTick(resize),
|
|
313
|
+
)
|
|
314
|
+
</script>
|
|
315
|
+
|
|
316
|
+
<style lang="postcss" src="./STextarea.css"></style>
|