@pgcorp/ui-kit 0.3.0 → 0.4.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.
Files changed (39) hide show
  1. package/README.md +36 -0
  2. package/docs/public-api.md +8 -0
  3. package/package.json +1 -1
  4. package/src/components/layout/SAppShell.vue +3 -2
  5. package/src/components/shared/complex/STree.vue +29 -4
  6. package/src/components/shared/complex/treeAdapter.ts +2 -1
  7. package/src/components/shared/complex/types.ts +1 -12
  8. package/src/components/shared/containers/SLeftSidebar.vue +7 -2
  9. package/src/components/shared/containers/SPageHeader.css +5 -0
  10. package/src/components/shared/containers/SPageHeader.vue +1 -1
  11. package/src/components/shared/containers/SPopover.vue +2 -1
  12. package/src/components/shared/containers/SSidebarGroup.vue +10 -5
  13. package/src/components/shared/containers/sidebar.ts +2 -1
  14. package/src/components/shared/controls/SButton.vue +10 -5
  15. package/src/components/shared/controls/SCombobox.vue +47 -5
  16. package/src/components/shared/controls/SInteractiveSurface.vue +2 -1
  17. package/src/components/shared/controls/SLink.ts +1 -17
  18. package/src/components/shared/controls/SLink.vue +26 -2
  19. package/src/components/shared/data-display/SDocBlock.vue +10 -10
  20. package/src/components/shared/data-display/SLinkedSystemsList.vue +3 -3
  21. package/src/components/shared/data-display/STable.css +39 -0
  22. package/src/components/shared/data-display/STable.vue +454 -67
  23. package/src/components/shared/data-display/STooltip.vue +2 -1
  24. package/src/components/shared/data-display/SVirtualList.ts +1 -16
  25. package/src/components/shared/data-display/SVirtualList.vue +18 -2
  26. package/src/components/shared/data-display/json.ts +1 -1
  27. package/src/components/shared/data-display/table.ts +4 -29
  28. package/src/components/shared/database/SDataGrid.vue +29 -8
  29. package/src/components/shared/database/SSqlEditor.vue +3 -2
  30. package/src/components/shared/database/dataGrid.ts +1 -17
  31. package/src/components/shared/navigation/STabs.vue +2 -1
  32. package/src/composables/uiPreferencesReset.ts +2 -1
  33. package/src/composables/useClipboard.ts +2 -1
  34. package/src/composables/useSidebarPanelState.ts +3 -2
  35. package/src/internal/es2020.ts +50 -0
  36. package/src/internal/inlineTokenEditorContract.ts +4 -3
  37. package/src/internal/pointerInteractionLease.ts +2 -1
  38. package/src/internal/semanticSizing.ts +2 -2
  39. package/src/styles/tokens.css +1 -0
package/README.md CHANGED
@@ -113,6 +113,42 @@ TypeScript показывают доступные subpaths при импорт
113
113
  The complete machine-readable catalog is declared in `package.json#exports`.
114
114
  IDEs and TypeScript expose the available subpaths during import.
115
115
 
116
+ ### Реестры и свободный ввод / Registries and custom values
117
+
118
+ `STable` владеет служебными колонками выбора и раскрытия. Consumer передаёт
119
+ controlled keys и доменный контент, не копирует checkbox, disclosure или CSS:
120
+
121
+ `STable` owns selection and disclosure columns. Consumers provide controlled
122
+ keys and domain content instead of copying checkboxes, disclosures, or CSS:
123
+
124
+ ```vue
125
+ <STable
126
+ v-model:selected-row-keys="selectedRowKeys"
127
+ v-model:expanded-row-keys="expandedRowKeys"
128
+ :columns="columns"
129
+ :data="rows"
130
+ :get-row-key="(row) => row.id"
131
+ :get-row-label="(row) => row.name"
132
+ selection-mode="multiple"
133
+ aria-label="Поставщики"
134
+ @row-contextmenu="openRowMenu"
135
+ >
136
+ <template #row-details="{ row }">
137
+ <SupplierDetails :supplier="row" />
138
+ </template>
139
+ </STable>
140
+ ```
141
+
142
+ Для служебных столбцов доступны `headerPresentation: 'assistive'`,
143
+ `width: '2xs'` и `width: 'content'`. `SCombobox` принимает новое строковое
144
+ значение через `allow-custom-value`; `SPageHeader` сочетает `appearance="bare"`
145
+ с `title-presentation="assistive"` для доступного заголовка без визуальной полосы.
146
+
147
+ Utility columns support `headerPresentation: 'assistive'`, `width: '2xs'`, and
148
+ `width: 'content'`. `SCombobox` accepts a new string value with
149
+ `allow-custom-value`; `SPageHeader` combines `appearance="bare"` with
150
+ `title-presentation="assistive"` for an accessible zero-chrome heading.
151
+
116
152
  ## Темы и стили / Themes and styles
117
153
 
118
154
  Для обычного приложения подключайте полный style entrypoint один раз:
@@ -18,6 +18,10 @@
18
18
 
19
19
  Компонентный contract выражается через typed props, slots, events и exposed
20
20
  methods. Внутренний DOM, CSS-классы и private modules не являются API.
21
+ Публичные source-SFC объявляют runtime props и events локально: consumer Vue
22
+ compiler регистрирует их без межфайлового filesystem type resolver.
23
+ Публикуемый production-source компилируется с `target/lib: ES2020`, совпадающим
24
+ с baseline `@vue/tsconfig/tsconfig.dom.json`, и не требует скрытых полифиллов.
21
25
 
22
26
  ## English
23
27
 
@@ -37,3 +41,7 @@ Primary groups:
37
41
 
38
42
  Component contracts are expressed through typed props, slots, events, and exposed
39
43
  methods. Internal DOM, CSS classes, and private modules are not API.
44
+ Public source SFCs declare runtime props and events locally, so the consumer Vue
45
+ compiler registers them without a cross-file filesystem type resolver.
46
+ Published production source compiles with the `target/lib: ES2020` baseline from
47
+ `@vue/tsconfig/tsconfig.dom.json` and does not require implicit polyfills.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgcorp/ui-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Typed Vue 3 design system with accessible components, semantic themes, and workbench patterns.",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, getCurrentInstance, useSlots } from 'vue'
3
3
  import type { RouteLocationRaw } from 'vue-router'
4
+ import { hasOwn } from '../../internal/es2020'
4
5
  import { useOwnedAttrs } from '../../internal/ownedAttrs'
5
6
  import { validateNonEmptyString } from '../../internal/runtimeContract'
6
7
  import SBadge from '../shared/data-display/SBadge.vue'
@@ -98,8 +99,8 @@ function toKebabCase(value: string): string {
98
99
  function hasExplicitProp(propName: string): boolean {
99
100
  const vnodeProps = componentInstance.vnode.props
100
101
  return vnodeProps !== null && (
101
- Object.hasOwn(vnodeProps, propName)
102
- || Object.hasOwn(vnodeProps, toKebabCase(propName))
102
+ hasOwn(vnodeProps, propName)
103
+ || hasOwn(vnodeProps, toKebabCase(propName))
103
104
  )
104
105
  }
105
106
 
@@ -32,8 +32,36 @@
32
32
  </ul>
33
33
  </template>
34
34
 
35
+ <script lang="ts">
36
+ import type {
37
+ SelectionKey,
38
+ TreeActionsLayout,
39
+ TreeDensity,
40
+ TreeExpandControl,
41
+ TreeModel,
42
+ TreeRowVariant,
43
+ TreeSelection,
44
+ TreeSelectionRail,
45
+ } from './types'
46
+
47
+ /** Публичный контракт дерева принадлежит source-SFC runtime. / The public tree contract is owned by the source-SFC runtime. */
48
+ export interface STreeProps<T extends object, K extends SelectionKey = SelectionKey> {
49
+ nodes: readonly T[]
50
+ model: TreeModel<T, K>
51
+ label: string
52
+ selection: TreeSelection<K>
53
+ expanded?: ReadonlySet<K>
54
+ density?: TreeDensity
55
+ actionsLayout?: TreeActionsLayout
56
+ expandControl?: TreeExpandControl
57
+ selectionRail?: TreeSelectionRail
58
+ rowVariant?: TreeRowVariant
59
+ }
60
+ </script>
61
+
35
62
  <script setup lang="ts" generic="T extends object, K extends SelectionKey">
36
63
  import { computed, nextTick, provide, ref, watch } from 'vue'
64
+ import { lastItem } from '../../../internal/es2020'
37
65
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
38
66
  import { assertSelectionKey, selectionKeyToken } from '../_internal/useSelectionRoving'
39
67
  import STreeNode from '../_internal/STreeNode.vue'
@@ -46,12 +74,9 @@ import {
46
74
  import { validateTreeModel } from './treeAdapter'
47
75
  import type {
48
76
  STreeNodeContentScope,
49
- STreeProps,
50
- SelectionKey,
51
77
  TreeActivationOrigin,
52
78
  TreeNodeActivationEvent,
53
79
  TreeNodeEvent,
54
- TreeSelection,
55
80
  } from './types'
56
81
 
57
82
  defineOptions({ inheritAttrs: false })
@@ -172,7 +197,7 @@ function handleTreeItemKeydown(item: RegisteredTreeItem<T, K>, event: KeyboardEv
172
197
  }
173
198
  if (event.key === 'End') {
174
199
  event.preventDefault()
175
- void focusItem(items.at(-1))
200
+ void focusItem(lastItem(items))
176
201
  return
177
202
  }
178
203
  if (event.key === 'ArrowRight' && item.hasChildren()) {
@@ -5,6 +5,7 @@ import {
5
5
  type SelectionIdentity,
6
6
  type SelectionKey,
7
7
  } from '../_internal/useSelectionRoving'
8
+ import { withErrorCause } from '../../../internal/es2020'
8
9
  import type { TreeModel } from './types'
9
10
 
10
11
  export interface FlatTreeAdapter<T extends object, K extends SelectionKey> {
@@ -130,7 +131,7 @@ function readModelValue<R>(
130
131
  try {
131
132
  return reader()
132
133
  } catch (error) {
133
- throw new TypeError(`STree: model.${field} failed at ${coordinate}`, { cause: error })
134
+ throw withErrorCause(new TypeError(`STree: model.${field} failed at ${coordinate}`), error)
134
135
  }
135
136
  }
136
137
 
@@ -51,18 +51,7 @@ export type TreeExpandControl = 'button' | 'row'
51
51
  export type TreeSelectionRail = 'hidden' | 'visible'
52
52
  export type TreeRowVariant = 'default' | 'table'
53
53
 
54
- export interface STreeProps<T extends object, K extends SelectionKey = SelectionKey> {
55
- nodes: readonly T[]
56
- model: TreeModel<T, K>
57
- label: string
58
- selection: TreeSelection<K>
59
- expanded?: ReadonlySet<K>
60
- density?: TreeDensity
61
- actionsLayout?: TreeActionsLayout
62
- expandControl?: TreeExpandControl
63
- selectionRail?: TreeSelectionRail
64
- rowVariant?: TreeRowVariant
65
- }
54
+ export type { STreeProps } from './STree.vue'
66
55
 
67
56
  export interface TreeNodeEvent<T extends object, K extends SelectionKey = SelectionKey> {
68
57
  node: T
@@ -33,7 +33,7 @@ import { type FunctionalComponent, type HTMLAttributes, type VNodeProps } from '
33
33
  import SPanel from './SPanel.vue';
34
34
  import SSidebarGroup from './SSidebarGroup.vue';
35
35
  import type { SAsyncStateContract } from '../feedback/SAsyncState.vue';
36
- import type { SidebarGroupEmits, SidebarSectionsState } from './sidebar';
36
+ import type { SidebarSectionsState } from './sidebar';
37
37
  import { useOwnedAttrs } from '../../../internal/ownedAttrs';
38
38
 
39
39
  defineOptions({ inheritAttrs: false });
@@ -48,6 +48,11 @@ export interface Props {
48
48
  scrollBehavior?: 'auto' | 'body' | 'self' | 'child';
49
49
  }
50
50
 
51
+ interface Emits {
52
+ 'update-sections': [state: SidebarSectionsState];
53
+ 'toggle-section': [key: string];
54
+ }
55
+
51
56
  withDefaults(defineProps<Props>(), {
52
57
  title: undefined,
53
58
  icon: undefined,
@@ -56,7 +61,7 @@ withDefaults(defineProps<Props>(), {
56
61
  scrollBehavior: 'child',
57
62
  });
58
63
 
59
- const emit = defineEmits<SidebarGroupEmits>();
64
+ const emit = defineEmits<Emits>();
60
65
  const ownedAttrs = useOwnedAttrs({ component: 'SLeftSidebar', owner: 'left-sidebar root' });
61
66
  </script>
62
67
 
@@ -19,6 +19,11 @@
19
19
  @apply px-0 pt-0;
20
20
  }
21
21
 
22
+ .s-page-header[data-appearance='bare'] {
23
+ @apply border-0 bg-transparent p-0 shadow-none dark:bg-transparent;
24
+ border-radius: 0;
25
+ }
26
+
22
27
  .s-page-header__copy {
23
28
  @apply min-w-0;
24
29
  }
@@ -8,7 +8,7 @@ withDefaults(defineProps<{
8
8
  description?: string
9
9
  eyebrow?: string
10
10
  headingLevel?: 1 | 2 | 3
11
- appearance?: 'plain' | 'panel'
11
+ appearance?: 'bare' | 'plain' | 'panel'
12
12
  size?: 'compact' | 'default'
13
13
  actionsLayout?: 'responsive' | 'inline' | 'stacked'
14
14
  titlePresentation?: 'visible' | 'assistive'
@@ -93,6 +93,7 @@ import SButton, {
93
93
  } from '../controls/SButton.vue';
94
94
  import { useFloatingPosition, type FloatingPlacement } from '../../../composables/useFloatingPosition';
95
95
  import { findRelativeTabTarget, getFocusableElements } from '../../../internal/focusNavigation';
96
+ import { lastItem } from '../../../internal/es2020';
96
97
  import { resolveInteractiveElement } from '../../../internal/interactiveElement';
97
98
  import { registerLayer, type LayerRegistration } from '../../../internal/layerStack';
98
99
  import { useOwnedAttrs } from '../../../internal/ownedAttrs';
@@ -495,7 +496,7 @@ function handlePanelTab(event: KeyboardEvent): void {
495
496
  const focusable = getFocusableElements(panel);
496
497
  const active = document.activeElement;
497
498
  const first = focusable[0];
498
- const last = focusable.at(-1);
499
+ const last = lastItem(focusable);
499
500
  const leavesPanel = focusable.length === 0
500
501
  || (event.shiftKey ? active === panel || active === first : active === last);
501
502
  if (!leavesPanel) return;
@@ -16,6 +16,7 @@ import {
16
16
  } from 'vue';
17
17
 
18
18
  import { useOwnedAttrs } from '../../../internal/ownedAttrs';
19
+ import { hasOwn, lastItem } from '../../../internal/es2020';
19
20
  import {
20
21
  sidebarGroupContextKey,
21
22
  type RegisteredSidebarSection,
@@ -27,7 +28,6 @@ import {
27
28
  createSidebarSectionsTransition,
28
29
  sidebarSectionsStateEqual,
29
30
  type SectionState,
30
- type SidebarGroupEmits,
31
31
  type SidebarSectionReplacement,
32
32
  type SidebarSectionsState,
33
33
  } from './sidebar';
@@ -38,7 +38,12 @@ const props = defineProps<{
38
38
  sectionsState: SidebarSectionsState;
39
39
  }>();
40
40
 
41
- const emit = defineEmits<SidebarGroupEmits>();
41
+ interface Emits {
42
+ 'update-sections': [state: SidebarSectionsState];
43
+ 'toggle-section': [key: string];
44
+ }
45
+
46
+ const emit = defineEmits<Emits>();
42
47
 
43
48
  const ownedAttrs = useOwnedAttrs({ component: 'SSidebarGroup', owner: 'sidebar group root' });
44
49
  const groupRef = ref<HTMLDivElement | null>(null);
@@ -147,7 +152,7 @@ function fitOpenSectionSizes(
147
152
 
148
153
  let delta = targetSize - [...sizes.values()].reduce((sum, size) => sum + size, 0);
149
154
  if (delta > 0) {
150
- const growTarget = flexible ?? openSections.at(-1);
155
+ const growTarget = flexible ?? lastItem(openSections);
151
156
  if (growTarget) sizes.set(growTarget.key, sizes.get(growTarget.key)! + delta);
152
157
  delta = 0;
153
158
  }
@@ -200,7 +205,7 @@ function registerSection(registration: SidebarSectionRegistration): () => void {
200
205
  if (!(registration.element instanceof HTMLElement)) {
201
206
  throw new TypeError('SSidebarGroup: registered section element must be an HTMLElement');
202
207
  }
203
- if (!Object.hasOwn(currentState(), registration.key)) {
208
+ if (!hasOwn(currentState(), registration.key)) {
204
209
  throw new Error(`SSidebarGroup: registered section '${registration.key}' has no controlled state`);
205
210
  }
206
211
  if (registrations.value.some((current) => current.key === registration.key)) {
@@ -371,7 +376,7 @@ function resizeSections(topKey: string, bottomKey: string, requestedTopSize: num
371
376
  );
372
377
  const delta = requestedTopSize - currentUpperSize;
373
378
  const shrinking = delta > 0 ? [...lower] : [...upper].reverse();
374
- const growing = delta > 0 ? upper.at(-1) : lower[0];
379
+ const growing = delta > 0 ? lastItem(upper) : lower[0];
375
380
  let transferable = Math.abs(delta);
376
381
  transferable = Math.min(
377
382
  transferable,
@@ -163,7 +163,7 @@ export function createSidebarSectionsTransition(
163
163
  if (typeof replacement.key !== 'string' || replacement.key.trim().length === 0) {
164
164
  throw new TypeError(`SSidebarGroup: replacements[${index}].key must be a non-empty string`);
165
165
  }
166
- if (!Object.hasOwn(current, replacement.key)) {
166
+ if (!hasOwn(current, replacement.key)) {
167
167
  throw new Error(`SSidebarGroup: cannot replace unknown section '${replacement.key}'`);
168
168
  }
169
169
  if (replacementsByKey.has(replacement.key)) {
@@ -204,3 +204,4 @@ export function sidebarSectionsStateEqual(
204
204
  && leftSection.minSize === rightSection.minSize;
205
205
  });
206
206
  }
207
+ import { hasOwn } from '../../../internal/es2020';
@@ -72,8 +72,8 @@ import { useTreeActionRegistration } from '../_internal/treeRuntime'
72
72
  import SBadge, { type BadgeSeverity } from '../data-display/SBadge.vue'
73
73
  import SProgressIndicator, { type SProgressIndicatorSize } from '../data-display/SProgressIndicator.vue'
74
74
  import type {
75
- HrefLinkDestination,
76
- RouterLinkDestination,
75
+ LinkBrowsingTarget,
76
+ LinkRouterTarget,
77
77
  } from './SLink'
78
78
 
79
79
  defineOptions({ inheritAttrs: false })
@@ -132,9 +132,14 @@ export type SButtonActionProps = SButtonPresentationProps & {
132
132
  type?: SButtonNativeType
133
133
  }
134
134
 
135
- export type SButtonNavigationProps = SButtonPresentationProps & (
136
- | (HrefLinkDestination & { type?: never })
137
- | (RouterLinkDestination & { type?: never })
135
+ interface SButtonNavigationOptions {
136
+ target?: LinkBrowsingTarget
137
+ rel?: string
138
+ }
139
+
140
+ export type SButtonNavigationProps = SButtonPresentationProps & SButtonNavigationOptions & (
141
+ | { href: string; to?: never; type?: never }
142
+ | { href?: never; to: LinkRouterTarget; type?: never }
138
143
  )
139
144
 
140
145
  /** Strict action/navigation XOR with a shared button presentation. */
@@ -62,7 +62,7 @@ export type SComboboxOptionGroup<TKey extends PublicSelectionKey = PublicSelecti
62
62
  export type SComboboxEntry<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionEntry<TKey>
63
63
  export type SComboboxFilterMode = 'local' | 'manual'
64
64
 
65
- export interface Props<TKey extends PublicSelectionKey = PublicSelectionKey> {
65
+ interface SharedProps<TKey extends PublicSelectionKey> {
66
66
  modelValue: TKey | null
67
67
  search: string
68
68
  options: readonly SComboboxEntry<TKey>[]
@@ -79,6 +79,18 @@ export interface Props<TKey extends PublicSelectionKey = PublicSelectionKey> {
79
79
  id?: string
80
80
  size?: 'sm' | 'md' | 'lg'
81
81
  }
82
+
83
+ /**
84
+ * Custom values require a string-compatible model because the editable text is the committed identity.
85
+ * Свободные значения требуют string-compatible model, потому что введённый текст становится identity.
86
+ */
87
+ export type Props<TKey extends PublicSelectionKey = PublicSelectionKey> = SharedProps<TKey> & {
88
+ allowCustomValue?: string extends TKey ? boolean : false
89
+ }
90
+
91
+ type ComponentProps<TKey extends PublicSelectionKey> = SharedProps<TKey> & {
92
+ allowCustomValue?: boolean
93
+ }
82
94
  </script>
83
95
 
84
96
  <script setup lang="ts" generic="TKey extends SelectionKey = string">
@@ -106,7 +118,7 @@ import SListbox, { type SListboxMoveIntent } from './SListbox.vue'
106
118
 
107
119
  defineOptions({ inheritAttrs: false })
108
120
 
109
- const props = withDefaults(defineProps<Props<TKey>>(), {
121
+ const props = withDefaults(defineProps<ComponentProps<TKey>>(), {
110
122
  filterMode: 'local',
111
123
  label: undefined,
112
124
  placeholder: 'Начните вводить для поиска',
@@ -119,6 +131,7 @@ const props = withDefaults(defineProps<Props<TKey>>(), {
119
131
  emptyLabel: 'Нет доступных вариантов',
120
132
  id: undefined,
121
133
  size: 'md',
134
+ allowCustomValue: false,
122
135
  })
123
136
  const emit = defineEmits<{
124
137
  'update:modelValue': [value: TKey | null]
@@ -156,13 +169,18 @@ const contract = computed(() => ({
156
169
  invalid: validateBoolean('SCombobox', 'invalid', props.invalid),
157
170
  emptyLabel: validateNonEmptyString('SCombobox', 'emptyLabel', props.emptyLabel),
158
171
  size: validateExactString('SCombobox', 'size', props.size, ['sm', 'md', 'lg'] as const),
172
+ allowCustomValue: validateBoolean('SCombobox', 'allowCustomValue', props.allowCustomValue),
159
173
  label: validateOptionalSelectionString('SCombobox', 'label', props.label),
160
174
  helpText: validateOptionalSelectionString('SCombobox', 'helpText', props.helpText),
161
175
  errorMessage: validateOptionalSelectionString('SCombobox', 'errorMessage', props.errorMessage),
162
176
  }))
163
177
  const inventory = computed(() => {
164
178
  const resolved = resolveSelectionInventory<TKey>('SCombobox', props.options)
165
- validateSelectionModel('SCombobox', 'single', props.modelValue, resolved)
179
+ if (contract.value.allowCustomValue && typeof props.modelValue === 'string') {
180
+ selectionKeyToken('SCombobox', props.modelValue, 'modelValue')
181
+ } else {
182
+ validateSelectionModel('SCombobox', 'single', props.modelValue, resolved)
183
+ }
166
184
  return resolved
167
185
  })
168
186
 
@@ -231,6 +249,29 @@ const { anchorRef, panelRef, floatingStyle } = useFloatingPosition({
231
249
 
232
250
  type CloseReason = 'selection' | 'escape' | 'tab' | 'outside'
233
251
 
252
+ function customValueFromSearch(): TKey | null {
253
+ if (!contract.value.allowCustomValue) return null
254
+ const value = validatedSearch.value.trim()
255
+ if (value.length === 0) return null
256
+
257
+ if (props.modelValue !== null) {
258
+ const currentToken = selectionKeyToken('SCombobox', props.modelValue, 'modelValue')
259
+ const currentOption = inventory.value.optionsByToken.get(currentToken)
260
+ if (currentOption?.option.label.trim() === value) return null
261
+ if (typeof props.modelValue === 'string' && props.modelValue === value) return null
262
+ }
263
+
264
+ return value as TKey
265
+ }
266
+
267
+ function commitCustomValue(): boolean {
268
+ const value = customValueFromSearch()
269
+ if (value === null) return false
270
+ emit('update:modelValue', value)
271
+ emit('update:search', String(value))
272
+ return true
273
+ }
274
+
234
275
  function inputElement(): HTMLInputElement {
235
276
  const api = inputRef.value
236
277
  if (!api) throw new Error('SCombobox: SInputText API is unavailable')
@@ -239,7 +280,7 @@ function inputElement(): HTMLInputElement {
239
280
 
240
281
  function close(reason: CloseReason): void {
241
282
  if (!isOpen.value) return
242
- void reason
283
+ if (reason === 'tab' || reason === 'outside') commitCustomValue()
243
284
  isOpen.value = false
244
285
  activeValue.value = null
245
286
  layerRegistration?.unregister({ restoreFocus: false })
@@ -305,7 +346,8 @@ async function onInputKeydown(event: KeyboardEvent): Promise<void> {
305
346
  }
306
347
  if (event.key === 'Enter' && isOpen.value) {
307
348
  event.preventDefault()
308
- listboxRef.value?.selectActive()
349
+ if (activeValue.value !== null) listboxRef.value?.selectActive()
350
+ else if (commitCustomValue()) close('selection')
309
351
  return
310
352
  }
311
353
  if (event.key === 'Escape' && isOpen.value) {
@@ -2,6 +2,7 @@
2
2
  import { computed, watchEffect, type VNodeChild } from 'vue'
3
3
  import { RouterLink, type RouteLocationRaw } from 'vue-router'
4
4
  import type { STooltipTriggerBinding } from '../data-display/STooltip.vue'
5
+ import { hasOwn } from '../../../internal/es2020'
5
6
  import { resolveLinkTarget } from '../../../internal/linkTarget'
6
7
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
7
8
  import {
@@ -195,7 +196,7 @@ const validatedTooltipTrigger = computed<STooltipTriggerBinding | undefined>(()
195
196
  }
196
197
  const record = candidate as Record<string, unknown>
197
198
  const unexpected = Object.keys(record).filter((key) => !TOOLTIP_TRIGGER_KEYS.includes(key as typeof TOOLTIP_TRIGGER_KEYS[number]))
198
- const missing = TOOLTIP_TRIGGER_KEYS.filter((key) => !Object.hasOwn(record, key))
199
+ const missing = TOOLTIP_TRIGGER_KEYS.filter((key) => !hasOwn(record, key))
199
200
  if (unexpected.length > 0 || missing.length > 0) {
200
201
  throw new Error(`SInteractiveSurface: tooltipTrigger имеет недопустимый набор ключей; missing=${missing.join(',')}; unexpected=${unexpected.join(',')}. / SInteractiveSurface: tooltipTrigger has an invalid key set; missing=${missing.join(',')}; unexpected=${unexpected.join(',')}.`)
201
202
  }
@@ -1,4 +1,3 @@
1
- import type { Component } from 'vue'
2
1
  import type {
3
2
  HistoryState,
4
3
  LocationQueryRaw,
@@ -67,19 +66,4 @@ export type RouterLinkDestination = LinkDestinationOptions & {
67
66
  /** Строгий XOR-контракт обычного destination. / Strict XOR contract for an ordinary destination. */
68
67
  export type LinkDestination = HrefLinkDestination | RouterLinkDestination
69
68
 
70
- /** Canonical presentation/state API owned by SLink. */
71
- export interface LinkPresentationProps {
72
- readonly variant?: 'default' | 'subtle' | 'brand' | 'navigation' | 'bottom-navigation' | 'document'
73
- readonly external?: boolean
74
- readonly active?: boolean
75
- readonly disabled?: boolean
76
- readonly width?: 'fit' | 'full'
77
- readonly size?: 'inherit' | 'sm' | 'md'
78
- readonly underline?: 'hover' | 'always' | 'none'
79
- readonly wrap?: 'normal' | 'anywhere'
80
- readonly contentKind?: 'text' | 'identifier'
81
- readonly leadingIcon?: Component
82
- }
83
-
84
- /** Полный discriminated public props contract SLink. / Complete discriminated public SLink props contract. */
85
- export type SLinkProps = LinkDestination & LinkPresentationProps
69
+ export type { LinkPresentationProps, Props as SLinkProps } from './SLink.vue'
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
+ type Component,
3
4
  computed,
4
5
  type VNodeChild,
5
6
  } from 'vue'
@@ -12,13 +13,36 @@ import {
12
13
  type PassiveContentContract,
13
14
  } from '../../../internal/passiveContentContract'
14
15
  import type {
15
- SLinkProps,
16
+ LinkBrowsingTarget,
17
+ LinkRouterTarget,
16
18
  } from './SLink'
17
19
 
18
20
  defineOptions({ inheritAttrs: false })
19
21
 
22
+ /** Canonical presentation/state API owned by SLink. */
23
+ export interface LinkPresentationProps {
24
+ readonly variant?: 'default' | 'subtle' | 'brand' | 'navigation' | 'bottom-navigation' | 'document'
25
+ readonly external?: boolean
26
+ readonly active?: boolean
27
+ readonly disabled?: boolean
28
+ readonly width?: 'fit' | 'full'
29
+ readonly size?: 'inherit' | 'sm' | 'md'
30
+ readonly underline?: 'hover' | 'always' | 'none'
31
+ readonly wrap?: 'normal' | 'anywhere'
32
+ readonly contentKind?: 'text' | 'identifier'
33
+ readonly leadingIcon?: Component
34
+ }
35
+
36
+ interface LinkDestinationOptions {
37
+ readonly target?: LinkBrowsingTarget
38
+ readonly rel?: string
39
+ }
40
+
20
41
  /** Exact href/to XOR plus the canonical link presentation API. */
21
- export type Props = SLinkProps
42
+ export type Props = LinkPresentationProps & LinkDestinationOptions & (
43
+ | { readonly href: string; readonly to?: never }
44
+ | { readonly href?: never; readonly to: LinkRouterTarget }
45
+ )
22
46
 
23
47
  const props = withDefaults(defineProps<Props>(), {
24
48
  href: undefined,
@@ -88,20 +88,20 @@ useInteractiveLeafRegistration({ owner: 'SDocBlock' });
88
88
 
89
89
  function escapeHighlightedCode(code: string): string {
90
90
  return code
91
- .replaceAll('&', '&amp;')
92
- .replaceAll('<', '&lt;')
93
- .replaceAll('>', '&gt;')
94
- .replaceAll('"', '&quot;')
95
- .replaceAll("'", '&#39;');
91
+ .replace(/&/gu, '&amp;')
92
+ .replace(/</gu, '&lt;')
93
+ .replace(/>/gu, '&gt;')
94
+ .replace(/"/gu, '&quot;')
95
+ .replace(/'/gu, '&#39;');
96
96
  }
97
97
 
98
98
  function escapeHtmlAttribute(value: string): string {
99
99
  return value
100
- .replaceAll('&', '&amp;')
101
- .replaceAll('<', '&lt;')
102
- .replaceAll('>', '&gt;')
103
- .replaceAll('"', '&quot;')
104
- .replaceAll("'", '&#39;');
100
+ .replace(/&/gu, '&amp;')
101
+ .replace(/</gu, '&lt;')
102
+ .replace(/>/gu, '&gt;')
103
+ .replace(/"/gu, '&quot;')
104
+ .replace(/'/gu, '&#39;');
105
105
  }
106
106
 
107
107
  const markdownRenderer = new marked.Renderer();
@@ -113,6 +113,7 @@ export interface Slots<T extends LinkedSystemListItem = LinkedSystemListItem> {
113
113
 
114
114
  <script setup lang="ts" generic="T extends LinkedSystemListItem = LinkedSystemListItem">
115
115
  import { computed, watchEffect } from 'vue'
116
+ import { withErrorCause } from '../../../internal/es2020'
116
117
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
117
118
  import {
118
119
  validateBoolean,
@@ -169,11 +170,10 @@ function validateExternalUrl(value: unknown, index: number): void {
169
170
  try {
170
171
  parsedUrl = new URL(externalUrl)
171
172
  } catch (error: unknown) {
172
- throw new TypeError(
173
+ throw withErrorCause(new TypeError(
173
174
  `SLinkedSystemsList: ${coordinate} должен быть абсолютным HTTP(S) URL или null; получено ${JSON.stringify(externalUrl)}. `
174
175
  + `/ SLinkedSystemsList: ${coordinate} must be an absolute HTTP(S) URL or null; received ${JSON.stringify(externalUrl)}.`,
175
- { cause: error },
176
- )
176
+ ), error)
177
177
  }
178
178
  if (
179
179
  (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:')