@pgcorp/ui-kit 0.5.0 → 0.7.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 (56) hide show
  1. package/README.md +39 -0
  2. package/docs/public-api.md +6 -0
  3. package/package.json +5 -1
  4. package/src/components/shared/_internal/SHeaderSurface.css +98 -0
  5. package/src/components/shared/_internal/SHeaderSurface.vue +80 -0
  6. package/src/components/shared/_internal/SToastItem.css +8 -8
  7. package/src/components/shared/_internal/SToastItem.vue +8 -1
  8. package/src/components/shared/_internal/useRadioGroup.ts +106 -0
  9. package/src/components/shared/containers/SPageHeader.vue +13 -20
  10. package/src/components/shared/controls/SButton.css +6 -6
  11. package/src/components/shared/controls/SButton.vue +9 -1
  12. package/src/components/shared/controls/SCheckbox.vue +14 -43
  13. package/src/components/shared/controls/SChoiceCards.vue +16 -39
  14. package/src/components/shared/controls/SField.css +0 -12
  15. package/src/components/shared/controls/SField.vue +14 -10
  16. package/src/components/shared/controls/SFieldGroup.css +1 -19
  17. package/src/components/shared/controls/SFieldGroup.vue +13 -10
  18. package/src/components/shared/controls/SFieldLabel.css +0 -1
  19. package/src/components/shared/controls/SFieldLabel.vue +2 -1
  20. package/src/components/shared/controls/SInteractiveSurface.css +3 -3
  21. package/src/components/shared/controls/SInteractiveSurface.vue +9 -2
  22. package/src/components/shared/controls/SMarker.css +16 -16
  23. package/src/components/shared/controls/SMarker.vue +27 -1
  24. package/src/components/shared/controls/SSegmentedControl.vue +15 -43
  25. package/src/components/shared/controls/SSwitch.vue +14 -32
  26. package/src/components/shared/data-display/SActionListItem.css +2 -2
  27. package/src/components/shared/data-display/SActionListItem.vue +8 -1
  28. package/src/components/shared/data-display/SBadge.vue +41 -18
  29. package/src/components/shared/data-display/SChip.vue +22 -10
  30. package/src/components/shared/data-display/SMessage.css +8 -8
  31. package/src/components/shared/data-display/SMessage.vue +8 -1
  32. package/src/components/shared/data-display/SProgressBar.css +12 -12
  33. package/src/components/shared/data-display/SProgressBar.vue +12 -6
  34. package/src/components/shared/data-display/SProgressIndicator.css +5 -5
  35. package/src/components/shared/data-display/SProgressIndicator.vue +9 -1
  36. package/src/components/shared/data-display/SSectionHeader.vue +17 -21
  37. package/src/components/shared/data-display/SStatus.css +10 -10
  38. package/src/components/shared/data-display/SStatus.vue +9 -1
  39. package/src/components/shared/data-display/STable.vue +27 -4
  40. package/src/components/shared/data-display/STooltip.css +4 -4
  41. package/src/components/shared/data-display/STooltip.vue +159 -29
  42. package/src/components/shared/data-display/STooltipTarget.css +26 -0
  43. package/src/components/shared/data-display/STooltipTarget.vue +94 -0
  44. package/src/components/shared/database/SDataGrid.vue +13 -0
  45. package/src/components/shared/navigation/STabs.vue +11 -3
  46. package/src/internal/fieldPresentation.ts +28 -0
  47. package/src/internal/ownedAttrs.ts +3 -0
  48. package/src/internal/pillSurface.ts +93 -0
  49. package/src/internal/semanticTone.ts +23 -0
  50. package/src/internal/useBinaryInput.ts +76 -0
  51. package/src/styles/style.css +114 -0
  52. package/src/styles/tokens.css +31 -0
  53. package/src/components/shared/containers/SPageHeader.css +0 -82
  54. package/src/components/shared/data-display/SBadge.css +0 -73
  55. package/src/components/shared/data-display/SChip.css +0 -59
  56. package/src/components/shared/data-display/SSectionHeader.css +0 -84
@@ -255,7 +255,7 @@ export interface STableProps<
255
255
  </script>
256
256
 
257
257
  <script setup lang="ts" generic="Row extends object = Record<string, unknown>, RowKey extends STableRowKey = STableRowKey, Field extends string = Extract<keyof Row, string>">
258
- import { computed, useId, useSlots } from 'vue'
258
+ import { computed, useId, useSlots, watch } from 'vue'
259
259
  import { ChevronDown, ChevronsUpDown, ChevronUp } from '../../icons/sputnigUiIcons'
260
260
  import { hasOwn } from '../../../internal/es2020'
261
261
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
@@ -548,6 +548,23 @@ const activeSorts = computed<readonly STableSort<Field>[]>(() => {
548
548
  return singleSort === null ? [] : [singleSort]
549
549
  })
550
550
 
551
+ const NO_PENDING_SORT = Symbol('STable.no-pending-sort')
552
+ let pendingSingleSort: STableSort<Field> | null | typeof NO_PENDING_SORT = NO_PENDING_SORT
553
+ let pendingMultipleSort: readonly STableSort<Field>[] | typeof NO_PENDING_SORT = NO_PENDING_SORT
554
+
555
+ const clearPendingSortIntent = (): void => {
556
+ pendingSingleSort = NO_PENDING_SORT
557
+ pendingMultipleSort = NO_PENDING_SORT
558
+ }
559
+
560
+ watch(validatedSortMode, clearPendingSortIntent, { flush: 'sync' })
561
+ watch(validatedSingleSort, () => {
562
+ pendingSingleSort = NO_PENDING_SORT
563
+ }, { deep: true, flush: 'sync' })
564
+ watch(validatedMultipleSort, () => {
565
+ pendingMultipleSort = NO_PENDING_SORT
566
+ }, { deep: true, flush: 'sync' })
567
+
551
568
  function sortIndex(column: STableColumn<Row, Field>): number {
552
569
  return activeSorts.value.findIndex((candidate) => candidate.key === column.field)
553
570
  }
@@ -592,8 +609,10 @@ function requestSort(column: STableColumn<Row, Field>): void {
592
609
  if (!column.sortable) return
593
610
  const key = column.field as Field
594
611
  if (validatedSortMode.value === 'multiple') {
595
- const current = activeSorts.value
596
- const index = sortIndex(column)
612
+ const current = pendingMultipleSort === NO_PENDING_SORT
613
+ ? activeSorts.value
614
+ : pendingMultipleSort
615
+ const index = current.findIndex((candidate) => candidate.key === key)
597
616
  let next: readonly STableSort<Field>[]
598
617
  if (index === -1) next = [...current, { key, direction: 'ascending' }]
599
618
  else if (current[index]!.direction === 'ascending') {
@@ -601,15 +620,19 @@ function requestSort(column: STableColumn<Row, Field>): void {
601
620
  ? { key: entry.key, direction: 'descending' }
602
621
  : entry)
603
622
  } else next = current.filter((_entry, candidateIndex) => candidateIndex !== index)
623
+ pendingMultipleSort = next.map((entry) => ({ ...entry }))
604
624
  emit('update:sorts', next)
605
625
  emit('multi-sort', next)
606
626
  } else {
607
- const current = activeSorts.value[0]
627
+ const current = pendingSingleSort === NO_PENDING_SORT
628
+ ? activeSorts.value[0]
629
+ : pendingSingleSort ?? undefined
608
630
  const next: STableSort<Field> | null = current?.key !== key
609
631
  ? { key, direction: 'ascending' }
610
632
  : current.direction === 'ascending'
611
633
  ? { key, direction: 'descending' }
612
634
  : null
635
+ pendingSingleSort = next === null ? null : { ...next }
613
636
  emit('update:sort', next)
614
637
  emit('sort', next)
615
638
  }
@@ -54,19 +54,19 @@
54
54
  @apply font-mono;
55
55
  }
56
56
 
57
- .s-tooltip__detail-value[data-tone='info'] {
57
+ .s-tooltip__detail-value[data-s-tone='info'] {
58
58
  @apply text-info-700 dark:text-info-300;
59
59
  }
60
60
 
61
- .s-tooltip__detail-value[data-tone='success'] {
61
+ .s-tooltip__detail-value[data-s-tone='success'] {
62
62
  @apply text-success-700 dark:text-success-300;
63
63
  }
64
64
 
65
- .s-tooltip__detail-value[data-tone='warn'] {
65
+ .s-tooltip__detail-value[data-s-tone='warn'] {
66
66
  @apply text-warn-700 dark:text-warn-300;
67
67
  }
68
68
 
69
- .s-tooltip__detail-value[data-tone='danger'] {
69
+ .s-tooltip__detail-value[data-s-tone='danger'] {
70
70
  @apply text-danger-700 dark:text-danger-300;
71
71
  }
72
72
 
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <slot :trigger="triggerBinding" :is-open="isOpen" />
2
+ <slot
3
+ :trigger="triggerBinding"
4
+ :focus-owner="focusOwnerBinding"
5
+ :pointer-target="pointerTargetBinding"
6
+ :is-open="isOpen"
7
+ />
3
8
  <teleport to="body">
4
9
  <transition
5
10
  enter-active-class="transition-opacity duration-[var(--s-motion-fast)]"
@@ -27,7 +32,7 @@
27
32
  <dd
28
33
  class="s-tooltip__detail-value"
29
34
  :data-presentation="detail.presentation ?? 'text'"
30
- :data-tone="detail.tone ?? 'neutral'"
35
+ :data-s-tone="resolveDetailToneBinding(detail.tone)['data-s-tone']"
31
36
  >
32
37
  <SBadge
33
38
  v-if="detail.presentation === 'badge'"
@@ -70,6 +75,7 @@ import {
70
75
  } from 'vue';
71
76
  import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs';
72
77
  import { hasOwn } from '../../../internal/es2020';
78
+ import { resolveSemanticToneBinding } from '../../../internal/semanticTone';
73
79
  import {
74
80
  resolveInteractiveElement,
75
81
  type ElementRefTarget,
@@ -127,9 +133,26 @@ export interface STooltipTriggerBinding {
127
133
  readonly onFocusout: (event: FocusEvent) => void;
128
134
  }
129
135
 
136
+ /** Focus/ARIA owner для split trigger без pointer geometry. / Focus and ARIA owner for a split trigger without pointer geometry. */
137
+ export interface STooltipFocusOwnerBinding {
138
+ readonly ref: VNodeRef;
139
+ readonly 'aria-describedby': string | undefined;
140
+ readonly onFocusin: () => void;
141
+ readonly onFocusout: (event: FocusEvent) => void;
142
+ }
143
+
144
+ /** Пассивная pointer/geometry target той же интерактивной композиции. / Passive pointer and geometry target in the same interactive composition. */
145
+ export interface STooltipPointerTargetBinding {
146
+ readonly ref: VNodeRef;
147
+ readonly onPointerenter: (event: PointerEvent) => void;
148
+ readonly onPointerleave: (event: PointerEvent) => void;
149
+ }
150
+
130
151
  /** Scoped-slot API явного trigger owner. / Scoped-slot API for the explicit trigger owner. */
131
152
  export interface STooltipTriggerSlotProps {
132
153
  readonly trigger: STooltipTriggerBinding;
154
+ readonly focusOwner: STooltipFocusOwnerBinding;
155
+ readonly pointerTarget: STooltipPointerTargetBinding;
133
156
  readonly isOpen: boolean;
134
157
  }
135
158
 
@@ -171,8 +194,11 @@ const layerZIndex = ref('var(--s-layer-floating)');
171
194
  let layerRegistration: LayerRegistration | null = null;
172
195
  let hideTimer: number | null = null;
173
196
  let showTimer: number | null = null;
174
- let triggerTarget: ElementRefTarget = null;
175
- let triggerElement: HTMLElement | null = null;
197
+ let combinedTarget: ElementRefTarget = null;
198
+ let focusOwnerTarget: ElementRefTarget = null;
199
+ let pointerTarget: ElementRefTarget = null;
200
+ let focusOwnerElement: HTMLElement | null = null;
201
+ let pointerTargetElement: HTMLElement | null = null;
176
202
  let pointerInside = false;
177
203
  let focusInside = false;
178
204
  let touchActive = false;
@@ -302,9 +328,15 @@ const detailSeverityByTone: Record<TooltipDetailTone, BadgeSeverity> = {
302
328
  };
303
329
  const resolveDetailSeverity = (tone: TooltipDetailTone | undefined): BadgeSeverity =>
304
330
  detailSeverityByTone[tone ?? 'neutral'];
331
+ const resolveDetailToneBinding = (tone: TooltipDetailTone | undefined) => resolveSemanticToneBinding(
332
+ 'STooltip',
333
+ 'detail.tone',
334
+ tone ?? 'neutral',
335
+ ['neutral', 'info', 'success', 'warn', 'danger'] as const,
336
+ );
305
337
  const placement = computed<FloatingPlacement>(() => `${props.position}-center`);
306
338
  const {
307
- anchorRef: triggerRef,
339
+ anchorRef: positionAnchorRef,
308
340
  panelRef: tooltipRef,
309
341
  floatingStyle,
310
342
  arrowStyle,
@@ -346,11 +378,11 @@ const closeImmediately = () => {
346
378
  };
347
379
 
348
380
  const openImmediately = () => {
349
- if (isOpen.value || !triggerElement) return;
381
+ if (isOpen.value || !focusOwnerElement || !pointerTargetElement) return;
350
382
  layerRegistration = registerLayer({
351
383
  kind: 'tooltip',
352
384
  root: () => tooltipRef.value,
353
- anchor: () => triggerRef.value,
385
+ anchor: () => positionAnchorRef.value,
354
386
  blocksParentFocusTrap: false,
355
387
  restoreTarget: null,
356
388
  requestDismiss: closeImmediately,
@@ -360,7 +392,7 @@ const openImmediately = () => {
360
392
  };
361
393
 
362
394
  const scheduleShow = () => {
363
- if (props.disabled || !hasContent.value || !triggerElement) return;
395
+ if (props.disabled || !hasContent.value || !focusOwnerElement || !pointerTargetElement) return;
364
396
  clearHideTimer();
365
397
  clearShowTimer();
366
398
  showTimer = window.setTimeout(() => {
@@ -412,37 +444,100 @@ const handleFocusIn = () => {
412
444
  };
413
445
 
414
446
  const handleFocusOut = (event: FocusEvent) => {
415
- if (event.relatedTarget instanceof Node && triggerElement?.contains(event.relatedTarget)) return;
447
+ if (event.relatedTarget instanceof Node && focusOwnerElement?.contains(event.relatedTarget)) return;
416
448
  focusInside = false;
417
449
  hideWhenInactive();
418
450
  };
419
451
 
420
- const commitTriggerElement = (element: HTMLElement | null) => {
452
+ const validateSplitComposition = (): void => {
453
+ if (!focusOwnerElement || !pointerTargetElement || combinedTarget !== null) return;
454
+ if (focusOwnerElement === pointerTargetElement) {
455
+ throw triggerContractError(
456
+ 'focusOwner и pointerTarget должны быть разными владельцами одной интерактивной композиции. '
457
+ + 'focusOwner and pointerTarget must be distinct owners in one interactive composition.',
458
+ );
459
+ }
460
+ };
461
+
462
+ const commitFocusOwnerElement = (element: HTMLElement | null) => {
421
463
  if (element !== null && !isFocusableElement(element)) {
422
464
  throw triggerContractError(
423
- 'trigger должен владеть canonical focus owner; используйте интерактивный UI-kit leaf. '
424
- + 'The trigger must own a canonical focus owner; use an interactive UI-kit leaf.',
465
+ 'trigger/focusOwner должен владеть canonical focus owner; используйте интерактивный UI-kit leaf. '
466
+ + 'trigger/focusOwner must own a canonical focus owner; use an interactive UI-kit leaf.',
425
467
  );
426
468
  }
427
- triggerElement = element;
428
- triggerRef.value = element;
469
+ focusOwnerElement = element;
429
470
  pointerInside = false;
430
471
  focusInside = false;
431
472
  touchActive = false;
432
473
  if (element === null) closeImmediately();
474
+ validateSplitComposition();
433
475
  };
434
476
 
435
- const bindTriggerElement: VNodeRef = (target) => {
436
- if (target === triggerTarget) return;
437
- if (target !== null && triggerTarget !== null) {
477
+ const commitPointerTargetElement = (element: HTMLElement | null) => {
478
+ if (element !== null && combinedTarget === null && isFocusableElement(element)) {
438
479
  throw triggerContractError(
439
- 'trigger binding применён более чем к одному leaf. '
440
- + 'The trigger binding was applied to more than one leaf.',
480
+ 'pointerTarget должен быть пассивным и не может создавать дополнительный tab stop. '
481
+ + 'pointerTarget must be passive and cannot create an additional tab stop.',
441
482
  );
442
483
  }
443
- triggerTarget = target;
484
+ pointerTargetElement = element;
485
+ positionAnchorRef.value = element;
486
+ pointerInside = false;
487
+ touchActive = false;
488
+ if (element === null) closeImmediately();
489
+ validateSplitComposition();
490
+ };
491
+
492
+ const assertBindingMode = (binding: 'combined' | 'focusOwner' | 'pointerTarget'): void => {
493
+ if (binding === 'combined' && (focusOwnerTarget !== null || pointerTarget !== null)) {
494
+ throw triggerContractError('combined trigger нельзя смешивать со split trigger. combined trigger cannot be mixed with a split trigger.');
495
+ }
496
+ if (binding !== 'combined' && combinedTarget !== null) {
497
+ throw triggerContractError('split trigger нельзя смешивать с combined trigger. A split trigger cannot be mixed with a combined trigger.');
498
+ }
499
+ };
500
+
501
+ const bindCombinedElement: VNodeRef = (target) => {
502
+ if (target === combinedTarget) return;
503
+ if (target !== null) {
504
+ assertBindingMode('combined');
505
+ if (combinedTarget !== null) {
506
+ throw triggerContractError('trigger binding применён более чем к одному leaf. The trigger binding was applied to more than one leaf.');
507
+ }
508
+ }
509
+ combinedTarget = target;
444
510
  if (target === null || target instanceof HTMLElement) {
445
- commitTriggerElement(target);
511
+ commitFocusOwnerElement(target);
512
+ commitPointerTargetElement(target);
513
+ }
514
+ };
515
+
516
+ const bindFocusOwnerElement: VNodeRef = (target) => {
517
+ if (target === focusOwnerTarget) return;
518
+ if (target !== null) {
519
+ assertBindingMode('focusOwner');
520
+ if (focusOwnerTarget !== null) {
521
+ throw triggerContractError('focusOwner binding применён более чем к одному leaf. focusOwner binding was applied to more than one leaf.');
522
+ }
523
+ }
524
+ focusOwnerTarget = target;
525
+ if (target === null || target instanceof HTMLElement) {
526
+ commitFocusOwnerElement(target);
527
+ }
528
+ };
529
+
530
+ const bindPointerTargetElement: VNodeRef = (target) => {
531
+ if (target === pointerTarget) return;
532
+ if (target !== null) {
533
+ assertBindingMode('pointerTarget');
534
+ if (pointerTarget !== null) {
535
+ throw triggerContractError('pointerTarget binding применён более чем к одному leaf. pointerTarget binding was applied to more than one leaf.');
536
+ }
537
+ }
538
+ pointerTarget = target;
539
+ if (target === null || target instanceof HTMLElement) {
540
+ commitPointerTargetElement(target);
446
541
  }
447
542
  };
448
543
 
@@ -467,7 +562,7 @@ const validatedTriggerDescribedBy = computed(() => {
467
562
  });
468
563
 
469
564
  const triggerBinding = computed<STooltipTriggerBinding>(() => ({
470
- ref: bindTriggerElement,
565
+ ref: bindCombinedElement,
471
566
  'aria-describedby': mergeIdReferences(
472
567
  validatedTriggerDescribedBy.value,
473
568
  isOpen.value ? tooltipId : undefined,
@@ -481,6 +576,22 @@ const triggerBinding = computed<STooltipTriggerBinding>(() => ({
481
576
  onFocusout: handleFocusOut,
482
577
  }));
483
578
 
579
+ const focusOwnerBinding = computed<STooltipFocusOwnerBinding>(() => ({
580
+ ref: bindFocusOwnerElement,
581
+ 'aria-describedby': mergeIdReferences(
582
+ validatedTriggerDescribedBy.value,
583
+ isOpen.value ? tooltipId : undefined,
584
+ ),
585
+ onFocusin: handleFocusIn,
586
+ onFocusout: handleFocusOut,
587
+ }));
588
+
589
+ const pointerTargetBinding = computed<STooltipPointerTargetBinding>(() => ({
590
+ ref: bindPointerTargetElement,
591
+ onPointerenter: handlePointerEnter,
592
+ onPointerleave: handlePointerLeave,
593
+ }));
594
+
484
595
  const handleWindowBlur = () => {
485
596
  if (isOpen.value) {
486
597
  closeImmediately();
@@ -530,20 +641,39 @@ watchEffect(() => {
530
641
  });
531
642
 
532
643
  onMounted(() => {
533
- if (triggerTarget === null) {
644
+ if (combinedTarget !== null && (focusOwnerTarget !== null || pointerTarget !== null)) {
645
+ throw triggerContractError('combined trigger нельзя смешивать со split trigger. combined trigger cannot be mixed with a split trigger.');
646
+ }
647
+ if (combinedTarget === null && (focusOwnerTarget === null || pointerTarget === null)) {
648
+ throw triggerContractError(
649
+ 'default slot обязан применить combined trigger либо обе половины split trigger: focusOwner и pointerTarget. '
650
+ + 'The default slot must apply the combined trigger or both split trigger bindings: focusOwner and pointerTarget.',
651
+ );
652
+ }
653
+ if (combinedTarget !== null) {
654
+ const element = resolveInteractiveElement('STooltip: trigger', combinedTarget);
655
+ commitFocusOwnerElement(element);
656
+ commitPointerTargetElement(element);
657
+ return;
658
+ }
659
+ if (focusOwnerTarget === null || pointerTarget === null) {
534
660
  throw triggerContractError(
535
- 'default slot обязан применить trigger binding ровно к одному leaf. '
536
- + 'The default slot must apply the trigger binding to exactly one leaf.',
661
+ 'split trigger требует focusOwner и pointerTarget. '
662
+ + 'A split trigger requires focusOwner and pointerTarget.',
537
663
  );
538
664
  }
539
- commitTriggerElement(resolveInteractiveElement('STooltip: trigger', triggerTarget));
665
+ commitFocusOwnerElement(resolveInteractiveElement('STooltip: focusOwner', focusOwnerTarget));
666
+ commitPointerTargetElement(resolveInteractiveElement('STooltip: pointerTarget', pointerTarget));
540
667
  });
541
668
 
542
669
  onBeforeUnmount(() => {
543
670
  closeImmediately();
544
- triggerTarget = null;
545
- triggerElement = null;
546
- triggerRef.value = null;
671
+ combinedTarget = null;
672
+ focusOwnerTarget = null;
673
+ pointerTarget = null;
674
+ focusOwnerElement = null;
675
+ pointerTargetElement = null;
676
+ positionAnchorRef.value = null;
547
677
  unbindWindowListeners();
548
678
  });
549
679
  </script>
@@ -0,0 +1,26 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-tooltip-target {
4
+ @apply inline-flex min-w-0 shrink-0 items-center justify-center align-middle;
5
+ }
6
+
7
+ .s-tooltip-target[data-appearance='keycap'] {
8
+ @apply border border-surface-300 bg-surface-100 font-mono font-semibold text-surface-700 shadow-sm;
9
+ border-radius: var(--radius-xs);
10
+ }
11
+
12
+ .s-tooltip-target[data-appearance='keycap'][data-size='xs'] {
13
+ @apply min-h-4 min-w-4 px-1 text-[0.625rem] leading-none;
14
+ }
15
+
16
+ .s-tooltip-target[data-appearance='keycap'][data-size='sm'] {
17
+ @apply min-h-5 min-w-5 px-1.5 text-xs leading-none;
18
+ }
19
+
20
+ .s-tooltip-target[data-appearance='keycap'][data-size='md'] {
21
+ @apply min-h-6 min-w-6 px-2 text-sm leading-none;
22
+ }
23
+
24
+ :global(.dark .s-tooltip-target[data-appearance='keycap']) {
25
+ @apply border-surface-600 bg-surface-800 text-surface-200;
26
+ }
@@ -0,0 +1,94 @@
1
+ <template>
2
+ <component
3
+ :is="rootTag"
4
+ ref="targetRef"
5
+ v-bind="ownedAttrs.bindings()"
6
+ class="s-tooltip-target"
7
+ :data-appearance="resolvedAppearance"
8
+ :data-size="resolvedSize"
9
+ @pointerenter="emit('pointerenter', $event)"
10
+ @pointerleave="emit('pointerleave', $event)"
11
+ >
12
+ <slot />
13
+ </component>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import { computed, ref, type VNodeChild } from 'vue'
18
+ import type { ElementApi } from '../../../internal/interactiveElement'
19
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
20
+ import { validateExactString } from '../../../internal/runtimeContract'
21
+
22
+ defineOptions({
23
+ name: 'STooltipTarget',
24
+ inheritAttrs: false,
25
+ })
26
+
27
+ export type STooltipTargetTag = 'span' | 'kbd'
28
+ export type STooltipTargetAppearance = 'plain' | 'keycap'
29
+ export type STooltipTargetSize = 'xs' | 'sm' | 'md'
30
+ export type STooltipTargetApi = ElementApi<HTMLElement>
31
+
32
+ /**
33
+ * Пассивная pointer/geometry target для split-контракта STooltip.
34
+ * Passive pointer and geometry target for the STooltip split contract.
35
+ */
36
+ export interface Props {
37
+ /** Семантический HTML root без собственной интерактивности. / Semantic HTML root without its own interactivity. */
38
+ as?: STooltipTargetTag
39
+ /** Нейтральная или keycap presentation. / Plain or keycap presentation. */
40
+ appearance?: STooltipTargetAppearance
41
+ /** Централизованная visual density target. / Centralized visual density of the target. */
42
+ size?: STooltipTargetSize
43
+ }
44
+
45
+ const props = withDefaults(defineProps<Props>(), {
46
+ as: 'span',
47
+ appearance: 'plain',
48
+ size: 'sm',
49
+ })
50
+ const emit = defineEmits<{
51
+ pointerenter: [event: PointerEvent]
52
+ pointerleave: [event: PointerEvent]
53
+ }>()
54
+ const slots = defineSlots<{
55
+ /** Пассивное содержимое target. / Passive target content. */
56
+ default: () => VNodeChild
57
+ }>()
58
+
59
+ if (!slots.default) {
60
+ throw new Error('STooltipTarget: default slot обязателен. / STooltipTarget: the default slot is required.')
61
+ }
62
+
63
+ const targetRef = ref<HTMLElement | null>(null)
64
+ const ownedAttrs = useOwnedAttrs({ component: 'STooltipTarget', owner: 'passive tooltip target' })
65
+ const resolvedAs = computed(() => validateExactString(
66
+ 'STooltipTarget',
67
+ 'as',
68
+ props.as,
69
+ ['span', 'kbd'] as const,
70
+ ))
71
+ const rootTag = computed(() => resolvedAs.value === 'kbd' ? 'kbd' : 'span')
72
+ const resolvedAppearance = computed(() => validateExactString(
73
+ 'STooltipTarget',
74
+ 'appearance',
75
+ props.appearance,
76
+ ['plain', 'keycap'] as const,
77
+ ))
78
+ const resolvedSize = computed(() => validateExactString(
79
+ 'STooltipTarget',
80
+ 'size',
81
+ props.size,
82
+ ['xs', 'sm', 'md'] as const,
83
+ ))
84
+
85
+ const getElement = (): HTMLElement => {
86
+ const element = targetRef.value
87
+ if (!element) throw new Error('STooltipTarget: native target element is unavailable')
88
+ return element
89
+ }
90
+
91
+ defineExpose<STooltipTargetApi>({ getElement })
92
+ </script>
93
+
94
+ <style lang="postcss" src="./STooltipTarget.css" scoped></style>
@@ -8,6 +8,9 @@
8
8
  no-content-padding
9
9
  content-layout="block"
10
10
  surface-overflow="clip"
11
+ :surface-tone="validatedPresentation === 'embedded' ? 'transparent' : 'default'"
12
+ :surface-border="validatedPresentation === 'embedded' ? 'none' : 'default'"
13
+ :surface-radius="validatedPresentation === 'embedded' ? 'none' : 'default'"
11
14
  >
12
15
  <template v-if="$slots.actions" #actions>
13
16
  <slot
@@ -113,6 +116,8 @@ export interface SDataGridProps<Key extends SDataGridColumnKey = SDataGridColumn
113
116
  emptyMessage?: string
114
117
  selectedRowId?: string
115
118
  maxHeight?: ScrollableViewportMaxBlockSize
119
+ /** Surface chrome для самостоятельного или вложенного grid. / Surface chrome for a standalone or embedded grid. */
120
+ presentation?: 'panel' | 'embedded'
116
121
  /** Accessible name без visible title. / Accessible name when there is no visible title. */
117
122
  ariaLabel?: string
118
123
  /** DOM id external visible title. / DOM id of an external visible title. */
@@ -168,6 +173,7 @@ const props = withDefaults(defineProps<SDataGridProps<ColumnKey>>(), {
168
173
  emptyMessage: 'Нет строк для отображения',
169
174
  selectedRowId: undefined,
170
175
  maxHeight: 'none',
176
+ presentation: 'panel',
171
177
  ariaLabel: undefined,
172
178
  ariaLabelledby: undefined,
173
179
  })
@@ -185,6 +191,7 @@ const CELL_KINDS = ['text', 'number', 'boolean', 'json', 'datetime', 'binary'] a
185
191
  const COLUMN_ALIGNMENTS = ['left', 'center', 'right'] as const
186
192
  const ROW_STATES = ['clean', 'inserted', 'updated', 'deleted'] as const
187
193
  const COMMIT_SOURCES = ['draft', 'value'] as const
194
+ const PRESENTATIONS = ['panel', 'embedded'] as const
188
195
 
189
196
  function assertReadonlyArray(value: unknown, coordinate: string): asserts value is readonly unknown[] {
190
197
  if (Array.isArray(value)) return
@@ -335,6 +342,12 @@ const resolvedAccessibleName = computed(() => {
335
342
  const validatedLoading = computed(() => validateBoolean('SDataGrid', 'loading', props.loading))
336
343
  const validatedDisabled = computed(() => validateBoolean('SDataGrid', 'disabled', props.disabled))
337
344
  const validatedReadOnly = computed(() => validateBoolean('SDataGrid', 'readOnly', props.readOnly))
345
+ const validatedPresentation = computed(() => validateExactString(
346
+ 'SDataGrid',
347
+ 'presentation',
348
+ props.presentation,
349
+ PRESENTATIONS,
350
+ ))
338
351
  const validatedMaxHeight = computed(() => validateScrollableViewportMaxBlockSize(
339
352
  'SDataGrid',
340
353
  'maxHeight',
@@ -1,6 +1,7 @@
1
1
  <template>
2
2
  <div
3
3
  v-bind="ownedAttrs.bindings()"
4
+ :data-s-tone="toneBinding['data-s-tone']"
4
5
  class="s-tabs"
5
6
  :data-testid="testId"
6
7
  :data-composition="composition"
@@ -9,7 +10,6 @@
9
10
  :data-density="tabDensity"
10
11
  :data-sizing="tabSizing"
11
12
  :data-list-width="tabListWidth"
12
- :data-tone="tone"
13
13
  :data-close-mode="closeButtonMode"
14
14
  :data-close-visibility="closeButtonVisibility"
15
15
  :data-radius="surfaceRadius"
@@ -58,6 +58,7 @@ import {
58
58
  type VNodeChild,
59
59
  } from 'vue'
60
60
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
61
+ import { resolveSemanticToneBinding } from '../../../internal/semanticTone'
61
62
  import { lastItem } from '../../../internal/es2020'
62
63
  import {
63
64
  tabsContextKey,
@@ -80,6 +81,7 @@ export type STabsComposition = 'managed' | 'custom'
80
81
  export type STabsVariant = 'default' | 'flat' | 'bottom' | 'icon-only' | 'icon-with-label'
81
82
  export type STabsActivationMode = 'automatic' | 'manual'
82
83
  export type STabsTone = 'primary' | 'success'
84
+ const TABS_TONES = ['primary', 'success'] as const satisfies readonly STabsTone[]
83
85
  export type STabsSizing =
84
86
  | 'content'
85
87
  | 'compact'
@@ -176,6 +178,12 @@ const slots = defineSlots<{
176
178
  controls?: () => VNodeChild
177
179
  }>()
178
180
  const ownedAttrs = useOwnedAttrs({ component: 'STabs', owner: 'tabs state and layout root' })
181
+ const toneBinding = computed(() => resolveSemanticToneBinding(
182
+ 'STabs',
183
+ 'tone',
184
+ props.tone,
185
+ TABS_TONES,
186
+ ))
179
187
 
180
188
  function assertStableKey(value: unknown, coordinate: string): asserts value is string {
181
189
  if (typeof value !== 'string' || !value.trim() || value !== value.trim()) {
@@ -569,12 +577,12 @@ defineExpose({
569
577
  --s-tab-selected-border-color: var(--color-primary-400);
570
578
  --s-tab-disabled-color: var(--color-surface-600);
571
579
  }
572
- .s-tabs[data-tone='success'] {
580
+ .s-tabs[data-s-tone='success'] {
573
581
  --s-tab-hover-color: var(--color-success-700);
574
582
  --s-tab-selected-color: var(--color-success-700);
575
583
  --s-tab-selected-border-color: var(--color-success-600);
576
584
  }
577
- :global(.dark) .s-tabs[data-tone='success'] {
585
+ :global(.dark) .s-tabs[data-s-tone='success'] {
578
586
  --s-tab-hover-color: var(--color-success-300);
579
587
  --s-tab-selected-color: var(--color-success-300);
580
588
  --s-tab-selected-border-color: var(--color-success-400);
@@ -0,0 +1,28 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+
3
+ export const FIELD_REQUIRED_CLASS = 'ml-0.5 text-danger-600 dark:text-danger-400'
4
+ export const FIELD_SUPPORT_CLASS = 'mt-1 min-w-0 whitespace-normal text-xs [overflow-wrap:anywhere]'
5
+ export const FIELD_HELP_CLASS = `${FIELD_SUPPORT_CLASS} text-surface-500 dark:text-surface-400`
6
+ export const FIELD_ERROR_CLASS = `${FIELD_SUPPORT_CLASS} text-danger-600 dark:text-danger-400`
7
+
8
+ export interface FieldSupportingTextOptions {
9
+ readonly baseId: ComputedRef<string>
10
+ readonly hasHelp: () => boolean
11
+ readonly hasError: () => boolean
12
+ }
13
+
14
+ /**
15
+ * Владеет exact ID и aria-describedby contract для help/error у field и fieldset.
16
+ * Owns the exact help/error ID and aria-describedby contract for fields and fieldsets.
17
+ */
18
+ export function useFieldSupportingText(options: FieldSupportingTextOptions) {
19
+ const helpId = computed(() => `${options.baseId.value}-help`)
20
+ const errorId = computed(() => `${options.baseId.value}-error`)
21
+ const describedBy = computed(() => {
22
+ const ids: string[] = []
23
+ if (options.hasHelp()) ids.push(helpId.value)
24
+ if (options.hasError()) ids.push(errorId.value)
25
+ return ids.length > 0 ? ids.join(' ') : undefined
26
+ })
27
+ return { describedBy, errorId, helpId } as const
28
+ }
@@ -294,6 +294,9 @@ export const ownedAttributeSchemas = Object.freeze({
294
294
  'data-testid': 'data',
295
295
  id: 'string',
296
296
  }),
297
+ STooltipTarget: defineOwnedAttributeSchema({
298
+ 'data-testid': 'data',
299
+ }),
297
300
  SVirtualList: defineOwnedAttributeSchema({}),
298
301
  SAsyncState: defineOwnedAttributeSchema({}),
299
302
  SDataGrid: defineOwnedAttributeSchema({}),