@bagelink/vue 1.15.63 → 1.15.65

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 (136) hide show
  1. package/dist/components/AccordionItem.vue.d.ts.map +1 -1
  2. package/dist/components/Avatar.vue.d.ts +6 -1
  3. package/dist/components/Avatar.vue.d.ts.map +1 -1
  4. package/dist/components/Badge.vue.d.ts.map +1 -1
  5. package/dist/components/Card.vue.d.ts +7 -0
  6. package/dist/components/Card.vue.d.ts.map +1 -1
  7. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  8. package/dist/components/EmptyState.vue.d.ts +43 -0
  9. package/dist/components/EmptyState.vue.d.ts.map +1 -0
  10. package/dist/components/Icon/Icon.vue.d.ts +13 -0
  11. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  12. package/dist/components/Image.vue.d.ts +26 -1
  13. package/dist/components/Image.vue.d.ts.map +1 -1
  14. package/dist/components/ListItem.vue.d.ts +9 -9
  15. package/dist/components/ListItem.vue.d.ts.map +1 -1
  16. package/dist/components/Menu.vue.d.ts.map +1 -1
  17. package/dist/components/Swiper.vue.d.ts +3 -3
  18. package/dist/components/calendar/CalendarPopover.vue.d.ts +10 -0
  19. package/dist/components/calendar/CalendarPopover.vue.d.ts.map +1 -1
  20. package/dist/components/charts/BarChart.vue.d.ts +34 -0
  21. package/dist/components/charts/BarChart.vue.d.ts.map +1 -0
  22. package/dist/components/charts/ChartTooltip.vue.d.ts +33 -0
  23. package/dist/components/charts/ChartTooltip.vue.d.ts.map +1 -0
  24. package/dist/components/charts/Donut.vue.d.ts +53 -0
  25. package/dist/components/charts/Donut.vue.d.ts.map +1 -0
  26. package/dist/components/charts/Funnel.vue.d.ts +53 -0
  27. package/dist/components/charts/Funnel.vue.d.ts.map +1 -0
  28. package/dist/components/charts/Gauge.vue.d.ts +28 -0
  29. package/dist/components/charts/Gauge.vue.d.ts.map +1 -0
  30. package/dist/components/charts/LineChart.vue.d.ts +37 -0
  31. package/dist/components/charts/LineChart.vue.d.ts.map +1 -0
  32. package/dist/components/charts/RadialBars.vue.d.ts +34 -0
  33. package/dist/components/charts/RadialBars.vue.d.ts.map +1 -0
  34. package/dist/components/charts/RankBars.vue.d.ts +27 -0
  35. package/dist/components/charts/RankBars.vue.d.ts.map +1 -0
  36. package/dist/components/charts/Sparkline.vue.d.ts +25 -0
  37. package/dist/components/charts/Sparkline.vue.d.ts.map +1 -0
  38. package/dist/components/charts/StatCard.vue.d.ts +28 -0
  39. package/dist/components/charts/StatCard.vue.d.ts.map +1 -0
  40. package/dist/components/charts/core/data.d.ts +46 -0
  41. package/dist/components/charts/core/data.d.ts.map +1 -0
  42. package/dist/components/charts/core/format.d.ts +13 -0
  43. package/dist/components/charts/core/format.d.ts.map +1 -0
  44. package/dist/components/charts/core/palette.d.ts +19 -0
  45. package/dist/components/charts/core/palette.d.ts.map +1 -0
  46. package/dist/components/charts/core/uid.d.ts +2 -0
  47. package/dist/components/charts/core/uid.d.ts.map +1 -0
  48. package/dist/components/charts/core/useChartAnim.d.ts +11 -0
  49. package/dist/components/charts/core/useChartAnim.d.ts.map +1 -0
  50. package/dist/components/charts/core/useChartFrame.d.ts +21 -0
  51. package/dist/components/charts/core/useChartFrame.d.ts.map +1 -0
  52. package/dist/components/charts/core/useScale.d.ts +16 -0
  53. package/dist/components/charts/core/useScale.d.ts.map +1 -0
  54. package/dist/components/charts/index.d.ts +12 -0
  55. package/dist/components/charts/index.d.ts.map +1 -0
  56. package/dist/components/form/inputs/RadioGroup.vue.d.ts +1 -0
  57. package/dist/components/form/inputs/RadioGroup.vue.d.ts.map +1 -1
  58. package/dist/components/form/inputs/RangeInput.vue.d.ts +13 -4
  59. package/dist/components/form/inputs/RangeInput.vue.d.ts.map +1 -1
  60. package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
  61. package/dist/components/index.d.ts +3 -1
  62. package/dist/components/index.d.ts.map +1 -1
  63. package/dist/components/layout/Layout.vue.d.ts +1 -1
  64. package/dist/components/layout/Layout.vue.d.ts.map +1 -1
  65. package/dist/components/layout/Panel.vue.d.ts +1 -1
  66. package/dist/components/layout/Panel.vue.d.ts.map +1 -1
  67. package/dist/components/layout/Timeline.types.d.ts +9 -0
  68. package/dist/components/layout/Timeline.types.d.ts.map +1 -0
  69. package/dist/components/layout/Timeline.vue.d.ts +42 -0
  70. package/dist/components/layout/Timeline.vue.d.ts.map +1 -0
  71. package/dist/components/layout/TimelineItem.vue.d.ts +37 -0
  72. package/dist/components/layout/TimelineItem.vue.d.ts.map +1 -0
  73. package/dist/components/layout/index.d.ts +3 -0
  74. package/dist/components/layout/index.d.ts.map +1 -1
  75. package/dist/dialog/Dialog.vue.d.ts +4 -0
  76. package/dist/dialog/Dialog.vue.d.ts.map +1 -1
  77. package/dist/index.cjs +110 -116
  78. package/dist/index.mjs +38059 -37009
  79. package/dist/style.css +1 -1
  80. package/package.json +2 -1
  81. package/src/components/AccordionItem.vue +24 -22
  82. package/src/components/Avatar.vue +49 -11
  83. package/src/components/Badge.vue +4 -7
  84. package/src/components/Card.vue +32 -2
  85. package/src/components/Dropdown.vue +14 -3
  86. package/src/components/EmptyState.vue +91 -0
  87. package/src/components/Icon/Icon.vue +118 -25
  88. package/src/components/Image.vue +70 -3
  89. package/src/components/ListItem.vue +43 -22
  90. package/src/components/Menu.vue +10 -2
  91. package/src/components/charts/BarChart.vue +197 -0
  92. package/src/components/charts/ChartTooltip.vue +74 -0
  93. package/src/components/charts/Donut.vue +219 -0
  94. package/src/components/charts/Funnel.vue +377 -0
  95. package/src/components/charts/Gauge.vue +90 -0
  96. package/src/components/charts/LineChart.vue +255 -0
  97. package/src/components/charts/RadialBars.vue +99 -0
  98. package/src/components/charts/RankBars.vue +72 -0
  99. package/src/components/charts/Sparkline.vue +90 -0
  100. package/src/components/charts/StatCard.vue +84 -0
  101. package/src/components/charts/core/data.ts +95 -0
  102. package/src/components/charts/core/format.ts +64 -0
  103. package/src/components/charts/core/palette.ts +52 -0
  104. package/src/components/charts/core/uid.ts +6 -0
  105. package/src/components/charts/core/useChartAnim.ts +60 -0
  106. package/src/components/charts/core/useChartFrame.ts +49 -0
  107. package/src/components/charts/core/useScale.ts +39 -0
  108. package/src/components/charts/index.ts +12 -0
  109. package/src/components/form/inputs/RadioGroup.vue +2 -1
  110. package/src/components/form/inputs/RangeInput.vue +43 -15
  111. package/src/components/form/inputs/SelectInput.vue +1 -19
  112. package/src/components/index.ts +3 -1
  113. package/src/components/layout/Timeline.types.ts +9 -0
  114. package/src/components/layout/Timeline.vue +54 -0
  115. package/src/components/layout/TimelineItem.vue +93 -0
  116. package/src/components/layout/index.ts +3 -0
  117. package/src/dialog/Dialog.vue +29 -1
  118. package/src/styles/bagel.css +1 -0
  119. package/src/styles/gradients.css +181 -0
  120. package/src/styles/layout.css +9 -0
  121. package/src/styles/theme.css +1 -1
  122. package/dist/components/analytics/BarChart.vue.d.ts +0 -47
  123. package/dist/components/analytics/BarChart.vue.d.ts.map +0 -1
  124. package/dist/components/analytics/KpiCard.vue.d.ts +0 -24
  125. package/dist/components/analytics/KpiCard.vue.d.ts.map +0 -1
  126. package/dist/components/analytics/LineChart.vue.d.ts +0 -35
  127. package/dist/components/analytics/LineChart.vue.d.ts.map +0 -1
  128. package/dist/components/analytics/PieChart.vue.d.ts +0 -53
  129. package/dist/components/analytics/PieChart.vue.d.ts.map +0 -1
  130. package/dist/components/analytics/index.d.ts +0 -5
  131. package/dist/components/analytics/index.d.ts.map +0 -1
  132. package/src/components/analytics/BarChart.vue +0 -262
  133. package/src/components/analytics/KpiCard.vue +0 -84
  134. package/src/components/analytics/LineChart.vue +0 -357
  135. package/src/components/analytics/PieChart.vue +0 -544
  136. package/src/components/analytics/index.ts +0 -4
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
- defineOptions({ name: 'BglImage' })
2
+ defineOptions({ name: 'BglImage', inheritAttrs: true })
3
3
  import { Skeleton, normalizeDimension, appendScript, awaitGlobal, normalizeURL, Icon, pathKeyToURL } from '@bagelink/vue'
4
- import { ref, watch } from 'vue'
4
+ import { computed, ref, useSlots, watch } from 'vue'
5
5
 
6
6
  interface ImageProps {
7
7
  src?: string
@@ -11,6 +11,16 @@ interface ImageProps {
11
11
  height?: string | number
12
12
  caption?: string
13
13
  modelValue?: string
14
+ /** Aspect ratio, e.g. "1", "2-3", "16-9" — maps to the `ratio-*` utility (keeps a stable box while loading). */
15
+ ratio?: string
16
+ /** Gradient shown behind/instead of the image — a tone (`purple`), a CSS gradient string, or `[from, to]`. Doubles as the fallback when the image fails or is absent. */
17
+ gradient?: string | [string, string]
18
+ /** object-fit for the image. @default 'cover' when `ratio`/`gradient` is set. */
19
+ fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
20
+ /** mix-blend-mode for blending the photo into the gradient (e.g. 'luminosity'). */
21
+ blend?: string
22
+ /** Rounded corners — true (default radius) or a px radius. */
23
+ rounded?: boolean | number
14
24
  }
15
25
 
16
26
  declare global {
@@ -92,10 +102,64 @@ async function loadImage() {
92
102
  }
93
103
 
94
104
  watch(() => [props.src, props.pathKey, props.modelValue], loadImage, { immediate: true })
105
+
106
+ // ── Framed mode (ratio / gradient / blend / rounded / overlay slot) ──────────
107
+ const slots = useSlots()
108
+ const framed = computed(() =>
109
+ props.ratio !== undefined || props.gradient !== undefined || props.blend !== undefined
110
+ || props.rounded !== undefined || !!slots.default)
111
+
112
+ // Resolve `gradient` into a CSS background. Accepts a tone name, a [from,to]
113
+ // pair (tone or color), or a raw CSS gradient/color string.
114
+ function toneToVar(t: string) {
115
+ return /^(--|#|rgb|hsl|var\(|linear|radial|conic)/.test(t) ? t : `var(--bgl-${t})`
116
+ }
117
+ const gradientCss = computed(() => {
118
+ const g = props.gradient
119
+ if (!g) return undefined
120
+ if (Array.isArray(g)) return `linear-gradient(150deg, ${toneToVar(g[0])}, ${toneToVar(g[1])})`
121
+ if (/^(linear|radial|conic)-gradient/.test(g)) return g
122
+ // single tone → subtle two-stop using the tone and a darker mix
123
+ const c = toneToVar(g)
124
+ return `linear-gradient(150deg, ${c}, color-mix(in srgb, ${c} 55%, #000))`
125
+ })
126
+
127
+ const ratioClass = computed(() => (props.ratio ? `ratio-${props.ratio}` : ''))
128
+ const radiusStyle = computed(() => {
129
+ if (props.rounded === undefined || props.rounded === false) return undefined
130
+ return typeof props.rounded === 'number' ? `${props.rounded}px` : 'var(--bgl-card-radius, 12px)'
131
+ })
132
+ const imgFit = computed(() => props.fit ?? ((props.ratio || props.gradient) ? 'cover' : undefined))
95
133
  </script>
96
134
 
97
135
  <template>
98
- <figcaption v-if="caption">
136
+ <!-- Framed mode: ratio box with gradient base, blended photo, and overlay slot. -->
137
+ <div
138
+ v-if="framed"
139
+ class="bgl-image-frame relative overflow-hidden"
140
+ :class="ratioClass"
141
+ :style="{
142
+ width: normalizeDimension(width),
143
+ height: ratio ? undefined : normalizeDimension(height),
144
+ background: gradientCss,
145
+ borderRadius: radiusStyle,
146
+ }"
147
+ >
148
+ <img
149
+ v-if="imageSrc"
150
+ :src="imageSrc"
151
+ :alt="alt"
152
+ class="bgl-image-frame-img absolute-fill"
153
+ :style="{ objectFit: imgFit, mixBlendMode: blend }"
154
+ >
155
+ <Skeleton v-else-if="!gradientCss && !loadingError" class="absolute-fill" />
156
+ <div v-else-if="loadingError && !gradientCss" class="flex-center absolute-fill error-image">
157
+ <Icon name="broken_image" />
158
+ </div>
159
+ <slot />
160
+ </div>
161
+
162
+ <figcaption v-else-if="caption">
99
163
  <img
100
164
  v-if="imageSrc"
101
165
  :src="imageSrc"
@@ -150,4 +214,7 @@ width: 100%;
150
214
  background-color: var(--bgl-skeleton-bg);
151
215
  }
152
216
 
217
+ .bgl-image-frame { display: block; }
218
+ .bgl-image-frame-img { width: 100%; height: 100%; }
219
+
153
220
  </style>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import type { IconType } from '@bagelink/vue'
2
+ import type { IconType, ThemeType } from '@bagelink/vue'
3
3
  import { Avatar, Icon } from '@bagelink/vue'
4
4
  import { computed } from 'vue'
5
5
 
@@ -22,13 +22,14 @@ const props = withDefaults(
22
22
  fullWidth?: boolean
23
23
  ellipsis?: boolean
24
24
  ripple?: boolean
25
- /** Make the whole row one hover/click target: the clickable button stretches
26
- to the trailing edge and the #end meta floats over it (still clickable).
27
- Great for simple rows with light trailing meta. Opt-in — the classic
28
- layout (only the text area clickable) remains the default so rows with
29
- wide trailing content aren't affected. Tune reserved room with the CSS
30
- var --bgl-list-item-end-space. */
31
- fullRow?: boolean
25
+ /** Theme accent for the leading icon-chip and hover/active tint.
26
+ Defaults to the surface's primary. e.g. `color="purple"`. */
27
+ color?: ThemeType
28
+ /** Card-style row: rounded, no divider, tinted hover. Use for standalone
29
+ selectable lists (vs. the default flush divided rows in a panel). */
30
+ rounded?: boolean
31
+ /** Visually mark as selected (for non-router selection lists). */
32
+ active?: boolean
32
33
  /** Render as an interactive row (cursor + hover) without needing a handler.
33
34
  Implied when `to`, `href`, or `onClick` is set. */
34
35
  clickable?: boolean
@@ -37,7 +38,6 @@ const props = withDefaults(
37
38
  {
38
39
  ellipsis: true,
39
40
  ripple: false,
40
- fullRow: false,
41
41
  }
42
42
  )
43
43
 
@@ -70,7 +70,8 @@ const bind = computed(() => {
70
70
  <template>
71
71
  <div
72
72
  class="flex space-between list-item-row"
73
- :class="{ 'no-border-list': props.flat, 'list-item-flush': props.fullWidth, 'list-item-fullrow': fullRow && isClickable }"
73
+ :class="{ 'no-border-list': props.flat || rounded, 'list-item-flush': props.fullWidth, 'list-item-fullrow': isClickable, 'list-item-rounded': rounded, 'list-item-active': active }"
74
+ :style="color ? { '--bgl-list-item-accent': `var(--bgl-${color})` } : undefined"
74
75
  >
75
76
  <!-- Content rendered before the clickable area (e.g. a drag handle, avatar
76
77
  or leading visual). Lives outside the clickable component so interacting
@@ -111,9 +112,9 @@ const bind = computed(() => {
111
112
  </p>
112
113
  </div>
113
114
  </component>
114
- <!-- Trailing meta. With fullRow, it overlaps the clickable button via a
115
- negative margin so the button spans the whole row underneath it; the
116
- end content itself stays on top and independently clickable. -->
115
+ <!-- Trailing meta. For clickable rows it floats over the end of the click
116
+ button so the button spans the whole row; the end content stays on top
117
+ and independently clickable. Tune room with --bgl-list-item-end-space. -->
117
118
  <div class="list-item-end flex align-items-center gap-05 flex-shrink-0">
118
119
  <slot name="end">
119
120
  <span v-if="end" class="list-item-endtext" :class="{ 'ellipsis-1': ellipsis }" v-text="end" />
@@ -140,11 +141,14 @@ gap: 0.5rem;
140
141
  independently clickable. The button reserves matching room so text never sits
141
142
  under the meta. Tune the reserved width with --bgl-list-item-end-space. */
142
143
  .list-item-fullrow {
143
- --bgl-list-item-end-space: 3rem;
144
+ --bgl-list-item-end-space: 3.5rem;
145
+ /* Keep the trailing meta aligned with the row's own inline padding (1rem) so it
146
+ doesn't crowd the edge. Override per-list with --bgl-list-item-end-inset. */
147
+ --bgl-list-item-end-inset: 1rem;
144
148
  }
145
149
  .list-item-fullrow .list-item-end {
146
150
  position: absolute;
147
- inset-inline-end: 0.5rem;
151
+ inset-inline-end: var(--bgl-list-item-end-inset);
148
152
  top: 50%;
149
153
  transform: translateY(-50%);
150
154
  z-index: 1;
@@ -156,7 +160,7 @@ pointer-events: none;
156
160
  pointer-events: auto;
157
161
  }
158
162
  .list-item-fullrow .list-item:not(.px-0) { padding-inline-end: var(--bgl-list-item-end-space); }
159
- .list-item-fullrow.list-item-flush .list-item-end { inset-inline-end: 0; }
163
+ .list-item-fullrow.list-item-flush .list-item-end { --bgl-list-item-end-inset: 0; }
160
164
  .list-item-row::after {
161
165
  content: '';
162
166
  position: absolute;
@@ -194,6 +198,12 @@ opacity: 0.5;
194
198
  pointer-events: none;
195
199
  }
196
200
 
201
+ /* Accent used by the icon-chip and (for rounded/active) the tint. Defaults to
202
+ primary; set per-item via `color` prop → --bgl-list-item-accent. */
203
+ .list-item-row {
204
+ --bgl-list-item-accent: var(--bgl-primary);
205
+ }
206
+
197
207
  /* Hover/active use Bagelink's translucent gray tint, which already flips from
198
208
  translucent-dark (light mode) to translucent-light (.bgl-dark-mode) — so it
199
209
  reads correctly on any background. Override per-list with --bgl-list-item-hover. */
@@ -201,8 +211,18 @@ pointer-events: none;
201
211
  .list-item.router-link-exact-active {
202
212
  background-color: var(--bgl-list-item-hover, var(--bgl-gray-tint));
203
213
  }
204
- .list-item.router-link-exact-active {
205
- background-color: var(--bgl-list-item-active, var(--bgl-primary-tint));
214
+ .list-item.router-link-exact-active,
215
+ .list-item-active .list-item {
216
+ background-color: var(--bgl-list-item-active, color-mix(in srgb, var(--bgl-list-item-accent) 12%, transparent));
217
+ color: var(--bgl-list-item-accent);
218
+ }
219
+
220
+ /* Rounded card-style rows: pill-radius, tinted accent hover, no divider. */
221
+ .list-item-rounded .list-item {
222
+ border-radius: var(--bgl-card-border-radius);
223
+ }
224
+ .list-item-rounded .list-item:hover {
225
+ background-color: var(--bgl-list-item-hover, color-mix(in srgb, var(--bgl-list-item-accent) 8%, transparent));
206
226
  }
207
227
 
208
228
  .notClickable,
@@ -215,15 +235,15 @@ cursor: default;
215
235
  filter: var(--bgl-hover-filter);
216
236
  }
217
237
 
218
- /* Leading icon chip */
238
+ /* Leading icon chip — tinted with the row accent (primary by default). */
219
239
  .list-item-icon {
220
240
  display: grid;
221
241
  place-items: center;
222
242
  width: 34px;
223
243
  height: 34px;
224
244
  border-radius: 9px;
225
- background: color-mix(in srgb, var(--bgl-primary) 10%, transparent);
226
- color: var(--bgl-primary);
245
+ background: color-mix(in srgb, var(--bgl-list-item-accent) 12%, transparent);
246
+ color: var(--bgl-list-item-accent);
227
247
  }
228
248
 
229
249
  /* Text block: tight, clear hierarchy */
@@ -238,13 +258,14 @@ line-height: 1.3;
238
258
  }
239
259
  .list-item-title {
240
260
  font-size: 0.9375rem;
241
- font-weight: 500;
261
+ font-weight: 600;
242
262
  line-height: 1.35;
243
263
  }
244
264
  .list-item-subtitle {
245
265
  font-size: 0.8125rem;
246
266
  opacity: 0.6;
247
267
  line-height: 1.35;
268
+ font-weight: 300;
248
269
  margin-top: 1px;
249
270
  }
250
271
  .list-item-endtext {
@@ -11,13 +11,13 @@ defineProps<{ items: NavLink[] }>()
11
11
  <div class="flex gap-025 m_gap-05">
12
12
  <template v-for="item in items" :key="item.label">
13
13
  <Dropdown v-if="item.children" :value="resolveI18n(item.label)" iconEnd="keyboard_arrow_down">
14
- <template #trigger="{ show }">
14
+ <template #trigger="{ show, shown }">
15
15
  <Btn thin flat :class="item.class" @click="show()">
16
16
  <Icon :name="item.icon" size="0.8" style="margin-bottom: -0.1rem" class=" line-height-0" />
17
17
  <p class="-ms-025">
18
18
  {{ resolveI18n(item.label) }}
19
19
  </p>
20
- <Icon name="keyboard_arrow_down" size="0.8" style="margin-bottom: -0.1rem" class=" line-height-0" />
20
+ <Icon name="keyboard_arrow_down" size="0.8" style="margin-bottom: -0.1rem" class="line-height-0 menu-chevron" :class="{ 'menu-chevron-open': shown }" />
21
21
  </Btn>
22
22
  </template>
23
23
  <Btn
@@ -53,6 +53,14 @@ background: var(--bgl-primary) !important;
53
53
  color: var(--bgl-white) !important;
54
54
  }
55
55
 
56
+ /* Chevron flips when its dropdown is open (mirror-pair → rotate, not swap). */
57
+ .menu-chevron {
58
+ transition: transform 0.2s ease;
59
+ }
60
+ .menu-chevron-open {
61
+ transform: rotate(180deg);
62
+ }
63
+
56
64
  </style>
57
65
 
58
66
  <style>
@@ -0,0 +1,197 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * BarChart — vertical bars, single or multi-series (grouped or stacked).
4
+ * Renders just the plot; wrap in your own Card for a titled panel.
5
+ *
6
+ * <BarChart :data="[12,18,9,22]" :labels="['Q1','Q2','Q3','Q4']" />
7
+ * <BarChart :series="[{name:'A',data:[…]},{name:'B',data:[…]}]" stacked />
8
+ */
9
+ import { computed, ref } from 'vue'
10
+ import { chartUid } from './core/uid'
11
+ import type { RawPoint, RawSeries } from './core/data'
12
+ import { allValues, normalizeSeries, sharedLabels } from './core/data'
13
+ import { resolveColor } from './core/palette'
14
+ import { formatLabel, formatValue, type ValueFormat } from './core/format'
15
+ import { band, linear, niceMax, ticks } from './core/useScale'
16
+ import { useChartFrame } from './core/useChartFrame'
17
+ import { useChartAnim } from './core/useChartAnim'
18
+ import ChartTooltip from './ChartTooltip.vue'
19
+
20
+ const props = withDefaults(defineProps<{
21
+ data?: RawPoint[]
22
+ series?: RawSeries[]
23
+ labels?: (string | number)[]
24
+ color?: string
25
+ stacked?: boolean
26
+ /** Soft top→bottom gradient on bars (opt-in; flat by default). */
27
+ gradient?: boolean
28
+ height?: number
29
+ grid?: boolean
30
+ radius?: number
31
+ currency?: string
32
+ prefix?: string
33
+ suffix?: string
34
+ maxLabels?: number
35
+ /** Minimum width per category (px). When the categories need more room than
36
+ the container has, the chart keeps this width per bar group and scrolls
37
+ horizontally instead of cramming — handy on mobile. Set 0 to disable. */
38
+ minBarWidth?: number
39
+ animated?: boolean
40
+ }>(), {
41
+ height: 220,
42
+ grid: true,
43
+ radius: 4,
44
+ maxLabels: 12,
45
+ minBarWidth: 28,
46
+ animated: true,
47
+ })
48
+
49
+ const el = ref<HTMLElement>()
50
+ const heightRef = computed(() => props.height)
51
+ const { width, height: plotH, pad, innerWidth, innerHeight, flipX } = useChartFrame(el, { height: heightRef })
52
+ const { progress } = useChartAnim({ el, enabled: props.animated, duration: 700 })
53
+
54
+ const series = computed(() => normalizeSeries(props.data, props.series, { labels: props.labels, defaultColor: props.color }))
55
+ const cats = computed(() => sharedLabels(series.value))
56
+
57
+ // Minimum plot width so dense data scrolls (mobile) instead of cramming.
58
+ const minWidth = computed(() =>
59
+ props.minBarWidth > 0 && cats.value.length
60
+ ? Math.ceil(cats.value.length * props.minBarWidth + pad.value.left + pad.value.right)
61
+ : 0,
62
+ )
63
+
64
+ const valueFmt = computed<ValueFormat>(() => ({ currency: props.currency, prefix: props.prefix, suffix: props.suffix }))
65
+ const axisFmt = computed<ValueFormat>(() => ({ ...valueFmt.value, compact: true }))
66
+
67
+ const stackTotals = computed(() => cats.value.map((_, i) => series.value.reduce((sum, s) => sum + (s.points[i]?.y ?? 0), 0)))
68
+ const yMax = computed(() => niceMax(props.stacked ? Math.max(0, ...stackTotals.value) : Math.max(0, ...allValues(series.value))))
69
+ const sy = computed(() => linear(0, yMax.value, innerHeight.value, 0))
70
+ const yTicks = computed(() => (props.grid ? ticks(0, yMax.value, 4) : []))
71
+
72
+ const groups = computed(() => band(cats.value.length, innerWidth.value, 0.3))
73
+ const subBand = computed(() => {
74
+ const n = props.stacked ? 1 : series.value.length
75
+ const w = groups.value.bandWidth / n
76
+ return { width: w, at: (si: number) => si * w }
77
+ })
78
+
79
+ interface Bar { x: number; y: number; w: number; h: number; color: string; cat: number }
80
+ const bars = computed<Bar[]>(() => {
81
+ const out: Bar[] = []
82
+ cats.value.forEach((lbl, i) => {
83
+ const groupStart = groups.value.start(i)
84
+ let stackY = innerHeight.value
85
+ series.value.forEach((s, si) => {
86
+ const v = s.points[i]?.y ?? 0
87
+ const color = resolveColor(s.color, si)
88
+ const full = innerHeight.value - sy.value(v)
89
+ const h = full * progress.value
90
+ const w = subBand.value.width
91
+ const x = props.stacked ? groupStart : groupStart + subBand.value.at(si)
92
+ const y = props.stacked ? (stackY - h) : (innerHeight.value - h)
93
+ if (props.stacked) stackY -= full
94
+ out.push({ x: flipX(x + w / 2) - w / 2, y, w, h, color, cat: i })
95
+ })
96
+ })
97
+ return out
98
+ })
99
+
100
+ const labelStride = computed(() => Math.max(1, Math.ceil(cats.value.length / props.maxLabels)))
101
+
102
+ // Gradient defs: one per distinct bar color.
103
+ const uid = chartUid('bar')
104
+ const gradColors = computed(() => [...new Set(bars.value.map(b => b.color))])
105
+ const gradIdFor = (color: string) => `${uid}-${gradColors.value.indexOf(color)}`
106
+
107
+ // ── Shared hover tooltip (mouse-follow, per category) ────────────────────────
108
+ const hoverIdx = ref<number | null>(null)
109
+ const mouseX = ref(0)
110
+ function onMove(e: MouseEvent) {
111
+ const host = el.value
112
+ if (!host || !cats.value.length) return
113
+ const rect = host.getBoundingClientRect()
114
+ let local = e.clientX - rect.left - pad.value.left
115
+ if (flipX(0) !== 0) local = innerWidth.value - local
116
+ const i = Math.floor((local / Math.max(1, innerWidth.value)) * cats.value.length)
117
+ hoverIdx.value = Math.min(cats.value.length - 1, Math.max(0, i))
118
+ mouseX.value = e.clientX - rect.left
119
+ }
120
+ function onLeave() { hoverIdx.value = null }
121
+
122
+ const hoverLabel = computed(() => (hoverIdx.value == null ? '' : formatLabel(cats.value[hoverIdx.value])))
123
+ const hoverRows = computed(() => {
124
+ if (hoverIdx.value == null) return []
125
+ return series.value.map((s, si) => ({
126
+ name: s.name,
127
+ color: resolveColor(s.color, si),
128
+ value: formatValue(s.points[hoverIdx.value as number]?.y ?? 0, valueFmt.value),
129
+ }))
130
+ })
131
+ const hoverTotal = computed(() => {
132
+ if (hoverIdx.value == null || !props.stacked) return null
133
+ return formatValue(stackTotals.value[hoverIdx.value] ?? 0, valueFmt.value)
134
+ })
135
+ const tooltipStyle = computed(() => {
136
+ const half = 70
137
+ const x = Math.min(Math.max(mouseX.value, half), Math.max(half, width.value - half))
138
+ return { left: `${x}px` }
139
+ })
140
+ </script>
141
+
142
+ <template>
143
+ <div class="bgl-chart-scroll w-100p overflow-x">
144
+ <div
145
+ ref="el" class="bgl-chart relative" :style="{ height: `${plotH}px`, minWidth: minWidth ? `${minWidth}px` : undefined }"
146
+ @mousemove="onMove" @mouseleave="onLeave"
147
+ >
148
+ <svg v-if="width" :width="width" :height="plotH" class="display-block overflow-hidden">
149
+ <defs v-if="gradient">
150
+ <linearGradient v-for="c in gradColors" :id="gradIdFor(c)" :key="c" x1="0" y1="0" x2="0" y2="1">
151
+ <stop offset="0%" :stop-color="c" stop-opacity="1" />
152
+ <stop offset="100%" :stop-color="c" stop-opacity="0.55" />
153
+ </linearGradient>
154
+ </defs>
155
+ <g :transform="`translate(${pad.left}, ${pad.top})`">
156
+ <g v-if="grid" class="bgl-chart__grid">
157
+ <g v-for="(t, i) in yTicks" :key="i">
158
+ <line :x1="0" :x2="innerWidth" :y1="sy(t)" :y2="sy(t)" />
159
+ <text class="bgl-chart__ytick" :x="-8" :y="sy(t) + 3">{{ formatValue(t, axisFmt) }}</text>
160
+ </g>
161
+ </g>
162
+
163
+ <rect
164
+ v-for="(b, i) in bars" :key="i"
165
+ :x="b.x" :y="b.y" :width="Math.max(0, b.w)" :height="Math.max(0, b.h)"
166
+ :rx="Math.min(radius, b.w / 2)" :fill="gradient ? `url(#${gradIdFor(b.color)})` : b.color"
167
+ class="bgl-chart__bar" :class="{ 'is-dim opacity-5': hoverIdx != null && hoverIdx !== b.cat }"
168
+ />
169
+
170
+ <g class="bgl-chart__xlabels">
171
+ <text
172
+ v-for="(lbl, i) in cats" v-show="i % labelStride === 0" :key="i"
173
+ :x="flipX(groups.center(i))" :y="innerHeight + 16" text-anchor="middle"
174
+ >{{ formatLabel(lbl) }}</text>
175
+ </g>
176
+ </g>
177
+ </svg>
178
+
179
+ <ChartTooltip
180
+ v-if="hoverIdx != null" :style="tooltipStyle"
181
+ :label="hoverLabel" :rows="hoverRows" :total="hoverTotal"
182
+ />
183
+ </div>
184
+ </div>
185
+ </template>
186
+
187
+ <style scoped>
188
+ .bgl-chart { direction: inherit; }
189
+ .bgl-chart__grid line { stroke: var(--bgl-border-color); stroke-width: 1; }
190
+ .bgl-chart__ytick,
191
+ .bgl-chart__xlabels text { fill: var(--bgl-gray); font-size: 11px; }
192
+ .bgl-chart__ytick { text-anchor: end; }
193
+ .bgl-chart__bar { transition: opacity 0.15s ease; outline: none; }
194
+ .bgl-chart__bar:focus,
195
+ .bgl-chart__bar:focus-visible { outline: none; }
196
+ [dir="rtl"] .bgl-chart__ytick { text-anchor: start; }
197
+ </style>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ChartTooltip — the single tooltip used by every chart (Line, Bar, Donut,
4
+ * Funnel). Renders a label header, optional hero value, per-series rows
5
+ * (dot · name · value), an optional total row, and an optional stats block.
6
+ *
7
+ * Positioned by the host via `style` (absolute left/top); the host owns
8
+ * mouse tracking. Non-interactive (pointer-events: none).
9
+ */
10
+ export interface TipRow { name: string; color?: string; value: string }
11
+ export interface TipStat { label: string; value: string }
12
+
13
+ defineOptions({ name: 'BglChartTooltip' })
14
+
15
+ withDefaults(defineProps<{
16
+ label?: string
17
+ /** Big focal number (used by single-value charts like the funnel). */
18
+ hero?: string
19
+ rows?: TipRow[]
20
+ total?: string | null
21
+ stats?: TipStat[]
22
+ /** Float above the anchor point instead of below it. */
23
+ above?: boolean
24
+ }>(), {
25
+ rows: () => [],
26
+ stats: () => [],
27
+ })
28
+ </script>
29
+
30
+ <template>
31
+ <div class="bgl-tip absolute pointer-events-none nowrap z-5 color-white line-height-13" :class="{ 'bgl-tip--above': above }">
32
+ <p v-if="label" class="bgl-tip__label m-0">{{ label }}</p>
33
+ <p v-if="hero" class="bgl-tip__hero m-0 tabular-nums">{{ hero }}</p>
34
+
35
+ <p v-for="(r, i) in rows" :key="i" class="bgl-tip__row flex align-items-center m-0">
36
+ <span v-if="r.color" class="bgl-tip__dot flex-shrink-0" :style="{ background: r.color }" />
37
+ <span v-if="rows.length > 1" class="bgl-tip__name">{{ r.name }}</span>
38
+ <span class="bgl-tip__val tabular-nums">{{ r.value }}</span>
39
+ </p>
40
+
41
+ <p v-if="total" class="bgl-tip__row bgl-tip__total flex align-items-center m-0">
42
+ <span class="bgl-tip__name">Total</span>
43
+ <span class="bgl-tip__val tabular-nums">{{ total }}</span>
44
+ </p>
45
+
46
+ <div v-if="stats.length" class="bgl-tip__stats flex column">
47
+ <p v-for="(s, i) in stats" :key="i" class="bgl-tip__row flex align-items-center m-0">
48
+ <span class="bgl-tip__name">{{ s.label }}</span>
49
+ <span class="bgl-tip__val tabular-nums">{{ s.value }}</span>
50
+ </p>
51
+ </div>
52
+ </div>
53
+ </template>
54
+
55
+ <style scoped>
56
+ .bgl-tip {
57
+ top: 0; transform: translateX(-50%); transform-origin: center;
58
+ background: var(--bgl-black); border-radius: 0.5rem; padding: 0.4rem 0.6rem;
59
+ font-size: 0.75rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); min-width: 7rem;
60
+ }
61
+ .bgl-tip--above { transform: translate(-50%, calc(-100% - 0.6rem)); }
62
+ .bgl-tip__label { margin-bottom: 0.2rem; opacity: 0.7; font-size: 0.7rem; }
63
+ .bgl-tip__hero { margin-bottom: 0.2rem; font-size: 1.05rem; font-weight: 700; }
64
+ .bgl-tip__row { gap: 0.4rem; }
65
+ .bgl-tip__dot { width: 8px; height: 8px; border-radius: 2px; }
66
+ .bgl-tip__name { opacity: 0.7; }
67
+ .bgl-tip__val { margin-inline-start: auto; font-weight: 600; }
68
+ .bgl-tip__total,
69
+ .bgl-tip__stats {
70
+ margin-top: 0.28rem; padding-top: 0.28rem;
71
+ border-top: 1px solid color-mix(in srgb, var(--bgl-white) 18%, transparent);
72
+ }
73
+ .bgl-tip__total .bgl-tip__name { opacity: 1; font-weight: 500; }
74
+ </style>