@pgcorp/ui-kit 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -219,6 +219,18 @@ axes, grid, legend, markers, labels, auto/fixed domains, and the data table. The
219
219
  component recomputes SVG geometry for its actual container, preserves readable
220
220
  typography, and wraps long category labels at narrow widths.
221
221
 
222
+ Форматированные подписи шкалы Y участвуют в расчёте поля графика. Крайние
223
+ подписи X привязаны к границам plot-area, поэтому `x-labels="auto"` сохраняет
224
+ полные начальное и конечное значения и не обрезает их краем SVG. Если ширины
225
+ контейнера физически недостаточно, визуальная подпись сокращается, а полное
226
+ значение остаётся в tooltip и доступной таблице данных.
227
+
228
+ Formatted Y-axis labels participate in plot-gutter calculation. Endpoint
229
+ X labels are anchored to the plot boundaries, so `x-labels="auto"` preserves
230
+ the full first and last values instead of clipping them at the SVG edge. When
231
+ the container is physically too narrow, the visual label is shortened while
232
+ the full value remains available through the tooltip and accessible data table.
233
+
222
234
  ```ts
223
235
  import type {
224
236
  SChartCategory,
@@ -266,6 +278,24 @@ nested button. `SPanel` uses `header-layout="auto"` to move actions below the
266
278
  title based on container width; `inline` and `stacked` remain explicit layout
267
279
  options.
268
280
 
281
+ Action/link chip всегда сохраняет видимый интерактивный контур, включая
282
+ `severity="secondary"` на subtle-поверхности; static chip не получает ложного
283
+ affordance. Для строк и карточек с дополнительными controls используйте
284
+ `SActionCard#actions` или `SActionListItem#actions`. `actions-layout`/
285
+ `trailing-layout` задаёт `inline`, `stacked` или container-responsive
286
+ композицию. Основное действие и trailing controls остаются sibling-элементами
287
+ внутри одной визуальной поверхности, без вложенных интерактивных элементов и
288
+ дублирования рамки.
289
+
290
+ Action and link chips always retain a visible interactive outline, including a
291
+ `secondary` chip on a subtle surface; static chips do not gain a false action
292
+ affordance. For rows and cards with additional controls, use
293
+ `SActionCard#actions` or `SActionListItem#actions`. The `actions-layout` and
294
+ `trailing-layout` props select `inline`, `stacked`, or container-responsive
295
+ composition. The primary action and trailing controls remain siblings inside a
296
+ single visual surface, with no nested interactive elements or duplicated
297
+ chrome.
298
+
269
299
  ### Лёгкий и редактируемый код / Lightweight and editable code
270
300
 
271
301
  `SCodeBlock` загружает CodeMirror отдельным async chunk только для
@@ -33,6 +33,10 @@ responsive geometry, semantic palette и доступную таблицу да
33
33
  async boundary; `presentation="plain"` является lightweight read-only контрактом
34
34
  без загрузки editor runtime. `SChip.surface` владеет action/link семантикой, а
35
35
  `SPanel.headerLayout` — inline/stacked/auto компоновкой заголовка и действий.
36
+ `SInteractiveSurface#actions` и использующие его `SActionCard`/
37
+ `SActionListItem` держат primary activation и дополнительные controls
38
+ sibling-элементами внутри одного chrome; `actionsLayout`/`trailingLayout`
39
+ управляют inline, stacked и container-responsive раскладкой.
36
40
 
37
41
  ## English
38
42
 
@@ -68,3 +72,7 @@ an accessible data table. `SMetricCard` accepts a compact chart through
68
72
  `presentation="plain"` is the lightweight read-only contract that does not load
69
73
  the editor runtime. `SChip.surface` owns action/link semantics, while
70
74
  `SPanel.headerLayout` owns inline/stacked/auto title and action composition.
75
+ `SInteractiveSurface#actions` and its `SActionCard`/`SActionListItem`
76
+ compositions keep primary activation and additional controls as siblings inside
77
+ one chrome; `actionsLayout`/`trailingLayout` control inline, stacked, and
78
+ container-responsive layout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgcorp/ui-kit",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",
@@ -14,6 +14,28 @@
14
14
  color var(--s-motion-standard) ease;
15
15
  }
16
16
 
17
+ .s-interactive-surface:has(.s-interactive-surface__actions > *) {
18
+ container-type: inline-size;
19
+ }
20
+
21
+ .s-interactive-surface__layout {
22
+ @apply grid min-h-0 min-w-0 w-full items-center;
23
+ grid-template-columns: minmax(0, 1fr);
24
+ }
25
+
26
+ .s-interactive-surface:has(.s-interactive-surface__actions > *) .s-interactive-surface__layout {
27
+ grid-template-columns: minmax(0, 1fr) auto;
28
+ }
29
+
30
+ .s-interactive-surface__primary {
31
+ @apply relative flex min-h-0 min-w-0;
32
+ }
33
+
34
+ .s-interactive-surface__actions {
35
+ @apply flex min-w-0 shrink-0 items-center;
36
+ gap: var(--s-space-control-gap);
37
+ }
38
+
17
39
  .s-interactive-surface[data-appearance='plain'] {
18
40
  @apply border-transparent bg-transparent dark:border-transparent dark:bg-transparent;
19
41
  }
@@ -46,7 +68,9 @@
46
68
  pointer-events: none;
47
69
  }
48
70
 
49
- .s-interactive-surface[data-fill='container'] { @apply h-full w-full; }
71
+ .s-interactive-surface[data-fill='container'],
72
+ .s-interactive-surface[data-fill='container'] .s-interactive-surface__layout,
73
+ .s-interactive-surface[data-fill='container'] .s-interactive-surface__primary { @apply h-full w-full; }
50
74
  .s-interactive-surface[data-density='compact'] { @apply text-sm; }
51
75
  .s-interactive-surface[data-density='comfortable'] { @apply text-base; }
52
76
  .s-interactive-surface[data-padding='none'] { @apply p-0; }
@@ -60,9 +84,31 @@
60
84
  .s-interactive-surface[data-s-tone='danger'] { @apply border-danger-200 bg-danger-50 dark:border-danger-800 dark:bg-danger-950; }
61
85
  .s-interactive-surface[data-selected='true'] { @apply border-primary-400 bg-primary-50 dark:border-primary-600 dark:bg-primary-950; }
62
86
  .s-interactive-surface[data-pressed='true'] { @apply border-primary-500 bg-primary-100 dark:border-primary-500 dark:bg-primary-950/70; }
63
- .s-interactive-surface[data-disabled='true'] { @apply cursor-not-allowed opacity-60; }
64
- .s-interactive-surface[data-content-align='start'] { @apply justify-start text-left; }
65
- .s-interactive-surface[data-content-align='center'] { @apply justify-center text-center; }
87
+ .s-interactive-surface[data-disabled='true'] { @apply cursor-not-allowed; }
88
+ .s-interactive-surface[data-disabled='true']:not(:has(.s-interactive-surface__actions > *)),
89
+ .s-interactive-surface[data-disabled='true']:has(.s-interactive-surface__actions > *) .s-interactive-surface__primary {
90
+ @apply opacity-60;
91
+ }
92
+ .s-interactive-surface[data-content-align='start'] .s-interactive-surface__content { @apply justify-start text-left; }
93
+ .s-interactive-surface[data-content-align='center'] .s-interactive-surface__content { @apply justify-center text-center; }
94
+
95
+ .s-interactive-surface[data-actions-layout='stacked']:has(.s-interactive-surface__actions > *) .s-interactive-surface__layout {
96
+ grid-template-columns: minmax(0, 1fr);
97
+ }
98
+
99
+ .s-interactive-surface[data-actions-layout='stacked'] .s-interactive-surface__actions {
100
+ @apply w-full;
101
+ }
102
+
103
+ @container (max-width: 32rem) {
104
+ .s-interactive-surface[data-actions-layout='responsive'] .s-interactive-surface__layout {
105
+ grid-template-columns: minmax(0, 1fr);
106
+ }
107
+
108
+ .s-interactive-surface[data-actions-layout='responsive'] .s-interactive-surface__actions {
109
+ @apply w-full;
110
+ }
111
+ }
66
112
 
67
113
  .s-interactive-surface[data-mode='action']:not([data-disabled='true']),
68
114
  .s-interactive-surface[data-mode='link']:not([data-disabled='true']) {
@@ -21,6 +21,7 @@ export type InteractiveSurfacePadding = 'none' | 'sm' | 'md'
21
21
  export type InteractiveSurfaceRadius = 'md' | 'lg' | 'xl'
22
22
  export type InteractiveSurfaceFill = 'content' | 'container'
23
23
  export type InteractiveSurfaceAppearance = 'card' | 'plain' | 'bare'
24
+ export type InteractiveSurfaceActionsLayout = 'responsive' | 'inline' | 'stacked'
24
25
 
25
26
  /**
26
27
  * Единственный semantic-контракт поверхности: passive, action или ровно одна link-target.
@@ -60,6 +61,7 @@ export interface Props {
60
61
  radius?: InteractiveSurfaceRadius
61
62
  fill?: InteractiveSurfaceFill
62
63
  appearance?: InteractiveSurfaceAppearance
64
+ actionsLayout?: InteractiveSurfaceActionsLayout
63
65
  contentAlign?: 'start' | 'center'
64
66
  /** Полный binding явного STooltip trigger, направляемый на activation owner. / Complete explicit STooltip trigger binding routed to the activation owner. */
65
67
  tooltipTrigger?: STooltipTriggerBinding
@@ -73,6 +75,7 @@ const props = withDefaults(defineProps<Props>(), {
73
75
  radius: 'lg',
74
76
  fill: 'content',
75
77
  appearance: 'card',
78
+ actionsLayout: 'inline',
76
79
  contentAlign: 'start',
77
80
  tooltipTrigger: undefined,
78
81
  })
@@ -80,9 +83,11 @@ const props = withDefaults(defineProps<Props>(), {
80
83
  const emit = defineEmits<{
81
84
  activate: [event: MouseEvent]
82
85
  }>()
83
- defineSlots<{
86
+ const slots = defineSlots<{
84
87
  /** Пассивное flow-содержимое поверхности. / Passive flow content of the surface. */
85
88
  default?: () => VNodeChild
89
+ /** Sibling controls inside the same visual chrome and outside the primary activation owner. */
90
+ actions?: () => VNodeChild
86
91
  }>()
87
92
 
88
93
  const ownedAttrs = useOwnedAttrs({ component: 'SInteractiveSurface', owner: 'interactive surface owner' })
@@ -297,40 +302,49 @@ function activateAction(event: MouseEvent): void {
297
302
  :data-radius="radius"
298
303
  :data-fill="fill"
299
304
  :data-appearance="appearance"
305
+ :data-actions-layout="actionsLayout"
306
+ :data-has-actions="Boolean(slots.actions) || undefined"
300
307
  :data-selected="selected || undefined"
301
308
  :data-pressed="surfacePressed"
302
309
  :data-disabled="surfaceDisabled || undefined"
303
310
  :data-content-align="contentAlign"
304
311
  >
305
- <button
306
- v-if="surfaceMode === 'action'"
307
- v-bind="activationBindings()"
308
- class="s-interactive-surface__activation"
309
- type="button"
310
- :disabled="surfaceDisabled"
311
- :aria-label="resolvedAccessibleLabel"
312
- :aria-pressed="surfacePressed"
313
- @click="activateAction"
314
- >
315
- <span class="s-interactive-surface__accessible-label">{{ resolvedAccessibleLabel }}</span>
316
- </button>
317
- <component
318
- v-else-if="surfaceMode === 'link'"
319
- :is="linkComponent"
320
- v-bind="linkBindings()"
321
- class="s-interactive-surface__activation"
322
- :aria-label="resolvedAccessibleLabel"
323
- :aria-current="selected ? 'page' : undefined"
324
- :aria-disabled="surfaceDisabled || undefined"
325
- :tabindex="surfaceDisabled ? -1 : undefined"
326
- @click="activateLink"
327
- >
328
- <span class="s-interactive-surface__accessible-label">{{ resolvedAccessibleLabel }}</span>
329
- </component>
330
- <div class="s-interactive-surface__content" data-passive-content="">
331
- <PassiveContentBoundary :contract="passiveContentContract">
332
- <slot />
333
- </PassiveContentBoundary>
312
+ <div class="s-interactive-surface__layout">
313
+ <div class="s-interactive-surface__primary">
314
+ <button
315
+ v-if="surfaceMode === 'action'"
316
+ v-bind="activationBindings()"
317
+ class="s-interactive-surface__activation"
318
+ type="button"
319
+ :disabled="surfaceDisabled"
320
+ :aria-label="resolvedAccessibleLabel"
321
+ :aria-pressed="surfacePressed"
322
+ @click="activateAction"
323
+ >
324
+ <span class="s-interactive-surface__accessible-label">{{ resolvedAccessibleLabel }}</span>
325
+ </button>
326
+ <component
327
+ v-else-if="surfaceMode === 'link'"
328
+ :is="linkComponent"
329
+ v-bind="linkBindings()"
330
+ class="s-interactive-surface__activation"
331
+ :aria-label="resolvedAccessibleLabel"
332
+ :aria-current="selected ? 'page' : undefined"
333
+ :aria-disabled="surfaceDisabled || undefined"
334
+ :tabindex="surfaceDisabled ? -1 : undefined"
335
+ @click="activateLink"
336
+ >
337
+ <span class="s-interactive-surface__accessible-label">{{ resolvedAccessibleLabel }}</span>
338
+ </component>
339
+ <div class="s-interactive-surface__content" data-passive-content="">
340
+ <PassiveContentBoundary :contract="passiveContentContract">
341
+ <slot />
342
+ </PassiveContentBoundary>
343
+ </div>
344
+ </div>
345
+ <div v-if="$slots.actions" class="s-interactive-surface__actions">
346
+ <slot name="actions" />
347
+ </div>
334
348
  </div>
335
349
  </div>
336
350
  </template>
@@ -1,6 +1,10 @@
1
1
  @reference "../../../styles/reference.css";
2
2
 
3
- .s-action-card { @apply grid min-h-0 min-w-0 items-start gap-2; grid-template-columns: minmax(0, 1fr) auto; }
3
+ .s-action-card { @apply min-h-0 min-w-0; }
4
4
  .s-action-card[data-fill='container'] { @apply h-full w-full; }
5
5
  .s-action-card__content { @apply flex min-h-0 min-w-0 w-full flex-1 flex-col; }
6
- .s-action-card__actions { @apply flex shrink-0 items-center gap-1; }
6
+ .s-action-card__actions { @apply flex min-w-0 max-w-full shrink-0 items-center gap-1; }
7
+ .s-action-card[data-actions-layout='responsive'] .s-action-card__actions,
8
+ .s-action-card[data-actions-layout='stacked'] .s-action-card__actions {
9
+ @apply w-full;
10
+ }
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import type { InteractiveSurfaceContract, InteractiveSurfaceDensity, InteractiveSurfaceFill, InteractiveSurfacePadding, InteractiveSurfaceRadius, InteractiveSurfaceTone } from '../controls/SInteractiveSurface.vue'
2
+ import type { VNodeChild } from 'vue'
3
+ import type { InteractiveSurfaceActionsLayout, InteractiveSurfaceContract, InteractiveSurfaceDensity, InteractiveSurfaceFill, InteractiveSurfacePadding, InteractiveSurfaceRadius, InteractiveSurfaceTone } from '../controls/SInteractiveSurface.vue'
3
4
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
4
5
  import SInteractiveSurface from '../controls/SInteractiveSurface.vue'
5
6
 
@@ -13,6 +14,7 @@ export interface Props {
13
14
  padding?: InteractiveSurfacePadding
14
15
  radius?: InteractiveSurfaceRadius
15
16
  fill?: InteractiveSurfaceFill
17
+ actionsLayout?: InteractiveSurfaceActionsLayout
16
18
  }
17
19
 
18
20
  withDefaults(defineProps<Props>(), {
@@ -22,14 +24,19 @@ withDefaults(defineProps<Props>(), {
22
24
  padding: 'md',
23
25
  radius: 'lg',
24
26
  fill: 'content',
27
+ actionsLayout: 'inline',
25
28
  })
26
29
 
27
30
  const emit = defineEmits<{ activate: [event: MouseEvent] }>()
31
+ defineSlots<{
32
+ default?: () => VNodeChild
33
+ actions?: () => VNodeChild
34
+ }>()
28
35
  const ownedAttrs = useOwnedAttrs({ component: 'SActionCard', owner: 'action card root' })
29
36
  </script>
30
37
 
31
38
  <template>
32
- <div v-bind="ownedAttrs.bindings()" class="s-action-card" :data-has-actions="Boolean($slots.actions) || undefined" :data-fill="fill" :data-mode="surface.mode">
39
+ <div v-bind="ownedAttrs.bindings()" class="s-action-card" :data-has-actions="Boolean($slots.actions) || undefined" :data-fill="fill" :data-mode="surface.mode" :data-actions-layout="actionsLayout">
33
40
  <SInteractiveSurface
34
41
  :surface="surface"
35
42
  :selected="selected"
@@ -38,11 +45,14 @@ const ownedAttrs = useOwnedAttrs({ component: 'SActionCard', owner: 'action card
38
45
  :padding="padding"
39
46
  :radius="radius"
40
47
  :fill="fill"
48
+ :actions-layout="actionsLayout"
41
49
  @activate="emit('activate', $event)"
42
50
  >
43
51
  <div class="s-action-card__content"><slot /></div>
52
+ <template v-if="$slots.actions" #actions>
53
+ <div class="s-action-card__actions"><slot name="actions" /></div>
54
+ </template>
44
55
  </SInteractiveSurface>
45
- <div v-if="$slots.actions" class="s-action-card__actions"><slot name="actions" /></div>
46
56
  </div>
47
57
  </template>
48
58
 
@@ -1,9 +1,8 @@
1
1
  @reference "../../../styles/reference.css";
2
2
 
3
3
  .s-action-list-item {
4
- @apply grid min-h-12 min-w-0 items-center gap-2;
4
+ @apply min-h-12 min-w-0;
5
5
  height: max-content;
6
- grid-template-columns: minmax(0, 1fr) auto;
7
6
  }
8
7
 
9
8
  .s-action-list-item[data-height-mode='fill'] {
@@ -94,7 +93,7 @@
94
93
  .s-action-list-item[data-density='compact'] .s-action-list-item__icon { @apply h-7 w-7; }
95
94
 
96
95
  .s-action-list-item[data-trailing-layout='stacked'] {
97
- grid-template-columns: minmax(0, 1fr);
96
+ @apply w-full;
98
97
  }
99
98
 
100
99
  .s-action-list-item[data-trailing-layout='stacked'] .s-action-list-item__trailing {
@@ -103,12 +102,12 @@
103
102
  }
104
103
 
105
104
  @container (max-width: 32rem) {
106
- .s-action-list-item[data-trailing-layout='responsive'] {
107
- grid-template-columns: minmax(0, 1fr);
108
- }
109
-
110
105
  .s-action-list-item[data-trailing-layout='responsive'] .s-action-list-item__trailing {
111
- @apply w-full justify-end pb-2 pr-2;
106
+ @apply w-full flex-wrap justify-end gap-y-1 pb-2 pr-2;
112
107
  padding-left: calc(3.5rem + var(--s-action-list-item-indent));
113
108
  }
109
+
110
+ .s-action-list-item[data-trailing-layout='responsive'] .s-action-list-item__trailing > * {
111
+ max-width: 100%;
112
+ }
114
113
  }
@@ -194,6 +194,7 @@ function emitAction(event: MouseEvent): void {
194
194
  :padding="surfacePadding"
195
195
  :radius="surfaceRadius"
196
196
  :fill="heightMode === 'fill' ? 'container' : 'content'"
197
+ :actions-layout="trailingLayout"
197
198
  @activate="emit('activate', $event)"
198
199
  >
199
200
  <div class="s-action-list-item__main" :class="{ 's-action-list-item__main--static': resolvedMode === 'static' }">
@@ -212,25 +213,27 @@ function emitAction(event: MouseEvent): void {
212
213
  </span>
213
214
  </span>
214
215
  </div>
216
+ <template v-if="hasTrailing" #actions>
217
+ <div class="s-action-list-item__trailing">
218
+ <slot name="status" />
219
+ <SBadge v-if="statusLabel" :severity="statusSeverity">{{ statusLabel }}</SBadge>
220
+ <SBadge v-for="badge in validatedBadges" :key="badge.id" :severity="badge.severity">
221
+ {{ badge.label }}
222
+ </SBadge>
223
+ <SButton
224
+ v-if="actionLabel"
225
+ size="sm"
226
+ :label="actionLabel"
227
+ :leading-icon="actionIcon"
228
+ :severity="actionSeverity"
229
+ :disabled="surfaceDisabled || actionDisabled"
230
+ @click="emitAction"
231
+ />
232
+ <slot name="actions" :disabled="surfaceDisabled || actionDisabled" :emit-action="emitAction" />
233
+ <component :is="trailingIcon" v-if="trailingIcon" class="s-action-list-item__trailing-icon" aria-hidden="true" />
234
+ </div>
235
+ </template>
215
236
  </SActionCard>
216
- <div v-if="hasTrailing" class="s-action-list-item__trailing">
217
- <slot name="status" />
218
- <SBadge v-if="statusLabel" :severity="statusSeverity">{{ statusLabel }}</SBadge>
219
- <SBadge v-for="badge in validatedBadges" :key="badge.id" :severity="badge.severity">
220
- {{ badge.label }}
221
- </SBadge>
222
- <SButton
223
- v-if="actionLabel"
224
- size="sm"
225
- :label="actionLabel"
226
- :leading-icon="actionIcon"
227
- :severity="actionSeverity"
228
- :disabled="surfaceDisabled || actionDisabled"
229
- @click="emitAction"
230
- />
231
- <slot name="actions" :disabled="surfaceDisabled || actionDisabled" :emit-action="emitAction" />
232
- <component :is="trailingIcon" v-if="trailingIcon" class="s-action-list-item__trailing-icon" aria-hidden="true" />
233
- </div>
234
237
  </div>
235
238
  </template>
236
239
 
@@ -352,7 +352,7 @@ watchEffect(() => {
352
352
  :y="tick.position"
353
353
  text-anchor="end"
354
354
  dominant-baseline="middle"
355
- >{{ tick.label }}</text>
355
+ >{{ tick.displayLabel }}<title v-if="tick.displayLabel !== tick.label">{{ tick.label }}</title></text>
356
356
  <text
357
357
  v-if="validatedYAxisLabel"
358
358
  class="s-chart__axis-title"
@@ -369,7 +369,7 @@ watchEffect(() => {
369
369
  :key="category.key"
370
370
  :x="category.x"
371
371
  :y="model.plotY + model.plotHeight + 18"
372
- text-anchor="middle"
372
+ :text-anchor="category.textAnchor"
373
373
  >
374
374
  <tspan
375
375
  v-for="(line, lineIndex) in category.lines"
@@ -8,12 +8,17 @@
8
8
  .s-chip[data-mode='action']:not([data-s-pill-disabled='true']),
9
9
  .s-chip[data-mode='link']:not([data-s-pill-disabled='true']) {
10
10
  @apply cursor-pointer;
11
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 42%, transparent);
11
12
  transition:
12
- filter var(--s-motion-standard) ease,
13
13
  box-shadow var(--s-motion-standard) ease;
14
14
  }
15
15
 
16
16
  .s-chip[data-mode='action']:not([data-s-pill-disabled='true']):hover,
17
17
  .s-chip[data-mode='link']:not([data-s-pill-disabled='true']):hover {
18
- filter: brightness(0.96);
18
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 68%, transparent);
19
+ }
20
+
21
+ .s-chip[data-mode='action']:active,
22
+ .s-chip[data-mode='link']:active {
23
+ box-shadow: inset 0 0 0 1px currentColor;
19
24
  }
@@ -79,6 +79,7 @@ export interface ChartLegendItem {
79
79
  export interface ChartTick {
80
80
  readonly value: number
81
81
  readonly label: string
82
+ readonly displayLabel: string
82
83
  readonly position: number
83
84
  }
84
85
 
@@ -87,6 +88,7 @@ export interface ChartCategoryLabel {
87
88
  readonly label: string
88
89
  readonly lines: readonly string[]
89
90
  readonly x: number
91
+ readonly textAnchor: 'start' | 'middle' | 'end'
90
92
  }
91
93
 
92
94
  export interface ChartGridLine {
@@ -158,6 +160,12 @@ interface PlotRect {
158
160
  readonly height: number
159
161
  }
160
162
 
163
+ interface ResolvedDomain {
164
+ readonly minimum: number
165
+ readonly maximum: number
166
+ readonly ticks: readonly number[]
167
+ }
168
+
161
169
  const SIZE_DIMENSIONS: Readonly<Record<SChartSize, ChartDimensions>> = {
162
170
  compact: { width: 360, height: 96 },
163
171
  sm: { width: 640, height: 240 },
@@ -393,6 +401,7 @@ function resolvePlot(
393
401
  xAxis: boolean,
394
402
  yAxis: boolean,
395
403
  dataLabels: SChartDataLabels,
404
+ yTickLabels: readonly string[],
396
405
  ): PlotRect {
397
406
  if (type === 'pie' || type === 'donut') {
398
407
  const inset = size === 'compact' ? 4 : 12
@@ -403,7 +412,15 @@ function resolvePlot(
403
412
  : (size === 'compact' ? 14 : 24)
404
413
  const right = size === 'compact' ? 6 : 16
405
414
  const bottom = xAxis ? (size === 'compact' ? 32 : 48) : (size === 'compact' ? 5 : 12)
406
- const left = yAxis ? (size === 'compact' ? 34 : 54) : (size === 'compact' ? 6 : 14)
415
+ const defaultLeft = yAxis ? (size === 'compact' ? 34 : 54) : (size === 'compact' ? 6 : 14)
416
+ const tickFontSize = size === 'compact' ? 9 : 11
417
+ const tickWidth = yAxis
418
+ ? Math.max(0, ...yTickLabels.map((label) => estimateAxisTextWidth(label, tickFontSize)))
419
+ : 0
420
+ const desiredLeft = yAxis ? Math.max(defaultLeft, Math.ceil(tickWidth + 16)) : defaultLeft
421
+ const minimumPlotWidth = Math.min(size === 'compact' ? 72 : 120, dimensions.width * 0.4)
422
+ const maximumLeft = Math.max(0, dimensions.width - right - minimumPlotWidth)
423
+ const left = Math.min(desiredLeft, maximumLeft)
407
424
  return {
408
425
  x: left,
409
426
  y: top,
@@ -412,6 +429,28 @@ function resolvePlot(
412
429
  }
413
430
  }
414
431
 
432
+ function estimateAxisTextWidth(label: string, fontSize: number): number {
433
+ const units = Array.from(label).reduce((total, character) => {
434
+ if (/\s/u.test(character)) return total + 0.36
435
+ if (/[.,:;!|ilI1'`]/u.test(character)) return total + 0.34
436
+ if (/[MW@%ШЩЖЮ]/u.test(character)) return total + 0.92
437
+ return total + 0.62
438
+ }, 0)
439
+ return units * fontSize
440
+ }
441
+
442
+ function truncateAxisLabelToWidth(label: string, availableWidth: number, fontSize: number): string {
443
+ if (estimateAxisTextWidth(label, fontSize) <= availableWidth) return label
444
+ const ellipsis = '…'
445
+ const characters = Array.from(label)
446
+ while (characters.length > 0) {
447
+ const candidate = `${characters.join('')}${ellipsis}`
448
+ if (estimateAxisTextWidth(candidate, fontSize) <= availableWidth) return candidate
449
+ characters.pop()
450
+ }
451
+ return ellipsis
452
+ }
453
+
415
454
  function truncateAxisWord(word: string, maximumCharacters: number): string {
416
455
  if (word.length <= maximumCharacters) return word
417
456
  return `${word.slice(0, Math.max(1, maximumCharacters - 1))}…`
@@ -461,16 +500,9 @@ function cartesianModel(
461
500
  series: readonly SChartSeries[],
462
501
  dimensions: ChartDimensions,
463
502
  plot: PlotRect,
503
+ resolvedDomain: ResolvedDomain,
504
+ yTickLabels: readonly string[],
464
505
  ): Omit<ChartModel, 'empty' | 'emptyReason' | 'slices' | 'centerX' | 'centerY'> {
465
- const domainValues = input.type === 'bar' && input.barMode === 'stacked'
466
- ? resolveStackedDomainValues(series, categories.length)
467
- : series.flatMap((item) => [...item.values])
468
- const resolvedDomain = resolveDomain(
469
- input.domain,
470
- domainValues,
471
- input.type === 'bar' || input.type === 'area',
472
- input.yTickCount,
473
- )
474
506
  const range = resolvedDomain.maximum - resolvedDomain.minimum
475
507
  const yAt = (value: number): number => plot.y + plot.height - ((value - resolvedDomain.minimum) / range) * plot.height
476
508
  const baselineY = Math.max(plot.y, Math.min(plot.y + plot.height, yAt(0)))
@@ -552,17 +584,57 @@ function cartesianModel(
552
584
  if (input.xLabels === 'endpoints') return index === 0 || index === categories.length - 1
553
585
  return categories.length <= 6 || index === 0 || index === categories.length - 1
554
586
  }
555
- const xLabels = categories.flatMap((category, index) => showXLabel(index)
556
- ? [{
587
+ const visibleCategories = categories
588
+ .map((category, index) => ({ category, index, x: xAt(index) }))
589
+ .filter(({ index }) => showXLabel(index))
590
+ const xLabels = visibleCategories.map(({ category, x }, visibleIndex): ChartCategoryLabel => {
591
+ const previous = visibleCategories[visibleIndex - 1]
592
+ const next = visibleCategories[visibleIndex + 1]
593
+ if (visibleCategories.length === 1) {
594
+ return {
595
+ key: category.key,
596
+ label: category.label,
597
+ lines: wrapAxisLabel(category.label, plot.width),
598
+ x: plot.x + plot.width / 2,
599
+ textAnchor: 'middle',
600
+ }
601
+ }
602
+ if (!previous) {
603
+ const rightBoundary = (x + next!.x) / 2
604
+ return {
605
+ key: category.key,
606
+ label: category.label,
607
+ lines: wrapAxisLabel(category.label, rightBoundary - plot.x),
608
+ x: plot.x,
609
+ textAnchor: 'start',
610
+ }
611
+ }
612
+ if (!next) {
613
+ const leftBoundary = (previous.x + x) / 2
614
+ return {
557
615
  key: category.key,
558
616
  label: category.label,
559
- lines: wrapAxisLabel(category.label, categoryBand),
560
- x: xAt(index),
561
- }]
562
- : [])
563
- const yTicks = resolvedDomain.ticks.map((value) => ({
617
+ lines: wrapAxisLabel(category.label, plot.x + plot.width - leftBoundary),
618
+ x: plot.x + plot.width,
619
+ textAnchor: 'end',
620
+ }
621
+ }
622
+ const leftBoundary = (previous.x + x) / 2
623
+ const rightBoundary = (x + next.x) / 2
624
+ return {
625
+ key: category.key,
626
+ label: category.label,
627
+ lines: wrapAxisLabel(category.label, 2 * Math.min(x - leftBoundary, rightBoundary - x)),
628
+ x,
629
+ textAnchor: 'middle',
630
+ }
631
+ })
632
+ const tickFontSize = input.size === 'compact' ? 9 : 11
633
+ const tickLabelWidth = Math.max(1, plot.x - 16)
634
+ const yTicks = resolvedDomain.ticks.map((value, index) => ({
564
635
  value,
565
- label: formatValue(input.valueFormatter, value, `yTick.${value}`),
636
+ label: yTickLabels[index]!,
637
+ displayLabel: truncateAxisLabelToWidth(yTickLabels[index]!, tickLabelWidth, tickFontSize),
566
638
  position: yAt(value),
567
639
  }))
568
640
  const horizontalGrid = yTicks.map((tick) => ({
@@ -725,6 +797,32 @@ export function resolveChartModel(input: ResolveChartModelInput): ChartModel {
725
797
  centerY: dimensions.height / 2,
726
798
  }
727
799
  }
800
+ if (input.type === 'pie' || input.type === 'donut') {
801
+ const plot = resolvePlot(
802
+ dimensions,
803
+ input.type,
804
+ input.size,
805
+ input.xAxis,
806
+ input.yAxis,
807
+ input.dataLabels,
808
+ [],
809
+ )
810
+ return radialModel(input, categories, series, dimensions, plot)
811
+ }
812
+ const domainValues = input.type === 'bar' && input.barMode === 'stacked'
813
+ ? resolveStackedDomainValues(series, categories.length)
814
+ : series.flatMap((item) => [...item.values])
815
+ const resolvedDomain = resolveDomain(
816
+ input.domain,
817
+ domainValues,
818
+ input.type === 'bar' || input.type === 'area',
819
+ input.yTickCount,
820
+ )
821
+ const yTickLabels = resolvedDomain.ticks.map((value) => formatValue(
822
+ input.valueFormatter,
823
+ value,
824
+ `yTick.${value}`,
825
+ ))
728
826
  const plot = resolvePlot(
729
827
  dimensions,
730
828
  input.type,
@@ -732,11 +830,17 @@ export function resolveChartModel(input: ResolveChartModelInput): ChartModel {
732
830
  input.xAxis,
733
831
  input.yAxis,
734
832
  input.dataLabels,
833
+ yTickLabels,
834
+ )
835
+ const model = cartesianModel(
836
+ input,
837
+ categories,
838
+ series,
839
+ dimensions,
840
+ plot,
841
+ resolvedDomain,
842
+ yTickLabels,
735
843
  )
736
- if (input.type === 'pie' || input.type === 'donut') {
737
- return radialModel(input, categories, series, dimensions, plot)
738
- }
739
- const model = cartesianModel(input, categories, series, dimensions, plot)
740
844
  return {
741
845
  ...model,
742
846
  empty: false,