@getdom/studio 0.1.1 → 0.2.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 (99) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +31 -0
  3. package/SKILL.md +21 -1
  4. package/package.json +4 -2
  5. package/public/llms.txt +13 -2
  6. package/src/pages/_layout/componentCatalog.generated.js +1185 -44
  7. package/src/pages/_layout/componentLoaders.js +41 -0
  8. package/src/pages/_layout/componentManager.js +38 -27
  9. package/src/pages/_layout/componentRuntimeRecords.js +3 -1
  10. package/src/pages/charts/_shared/ChartContainer.vue +67 -0
  11. package/src/pages/charts/_shared/ChartLegend.vue +22 -0
  12. package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
  13. package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
  14. package/src/pages/charts/_shared/XYChart.vue +140 -0
  15. package/src/pages/charts/_shared/XYRenderer.vue +90 -0
  16. package/src/pages/charts/_shared/chartProps.js +23 -0
  17. package/src/pages/charts/_shared/chartUtils.js +95 -0
  18. package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
  19. package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
  20. package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
  21. package/src/pages/charts/date-range-picker/dateRange.js +69 -0
  22. package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
  23. package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
  24. package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
  25. package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
  26. package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
  27. package/src/pages/components/_shared/media/useMediaInput.js +200 -0
  28. package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
  29. package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
  30. package/src/pages/components/button/DomButton.vue +10 -4
  31. package/src/pages/components/button/buttonStyles.js +5 -0
  32. package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
  33. package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
  34. package/src/pages/components/dialog/DomDialog.vue +338 -36
  35. package/src/pages/components/icon-button/DomIconButton.vue +8 -2
  36. package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
  37. package/src/pages/components/menu-stack/menuTree.js +35 -0
  38. package/src/pages/components/popover/DomPopover.vue +1 -53
  39. package/src/pages/components/popover/popover.css +50 -0
  40. package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
  41. package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
  42. package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
  43. package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
  44. package/src/pages/data/gantt/DomGantt.vue +788 -0
  45. package/src/pages/data/gantt/ganttTime.js +223 -0
  46. package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
  47. package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
  48. package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
  49. package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
  50. package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
  51. package/src/pages/designer/_shared/cssEditorContext.js +19 -0
  52. package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
  53. package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
  54. package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
  55. package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
  56. package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
  57. package/src/pages/designer/background-editor/backgroundModel.js +102 -0
  58. package/src/pages/designer/border-editor/BorderControl.vue +72 -0
  59. package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
  60. package/src/pages/designer/border-editor/borderModel.js +21 -0
  61. package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
  62. package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
  63. package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
  64. package/src/pages/designer/frame/frameLayout.js +93 -0
  65. package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
  66. package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
  67. package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
  68. package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
  69. package/src/pages/designer/stage/DesignerBounds.vue +31 -0
  70. package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
  71. package/src/pages/designer/stage/SpacingGuides.vue +33 -0
  72. package/src/pages/designer/stage/resizeGeometry.js +15 -0
  73. package/src/pages/designer/stage/snapGeometry.js +69 -0
  74. package/src/pages/designer/stage/snapPipeline.js +17 -0
  75. package/src/pages/designer/stage/spacingGeometry.js +129 -0
  76. package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
  77. package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
  78. package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
  79. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
  80. package/src/pages/forms/toggle/DomToggle.vue +1 -1
  81. package/src/pages/lib/headless/base.js +10 -5
  82. package/src/pages/lib/headless/dialog.js +25 -6
  83. package/src/pages/lib/headless/drawer.js +49 -21
  84. package/src/pages/lib/vue/index.d.ts +1161 -14
  85. package/src/pages/lib/vue/index.js +43 -0
  86. package/src/pages/mobile/_shared/interactionMath.js +46 -0
  87. package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
  88. package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
  89. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
  90. package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
  91. package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
  92. package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
  93. package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
  94. package/src/pages/mobile/shell/DomAppShell.vue +60 -9
  95. package/src/pages/mobile/stack/DomAppStack.vue +214 -55
  96. package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
  97. package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
  98. package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
  99. package/src/theme.css +13 -0
@@ -0,0 +1,41 @@
1
+ const lazyComponentModules = import.meta.glob([
2
+ '../components/*/Dom*.vue',
3
+ '../data/*/Dom*.vue',
4
+ '../charts/*/Dom*.vue',
5
+ '../dev/*/Dom*.vue',
6
+ '../forms/*/Dom*.vue',
7
+ '../mobile/*/Dom*.vue',
8
+ '../visual/*/Dom*.vue',
9
+ '../designer/*/Dom*.vue',
10
+ '!../components/playground/DomRenderer.vue',
11
+ '!../components/toast/DomToastItem.vue',
12
+ '!../forms/_shared/Dom*.vue',
13
+ ], { import: 'default' });
14
+ const lazyComponentSourceModules = import.meta.glob([
15
+ '../components/*/Dom*.vue',
16
+ '../data/*/Dom*.vue',
17
+ '../charts/*/Dom*.vue',
18
+ '../dev/*/Dom*.vue',
19
+ '../forms/*/Dom*.vue',
20
+ '../mobile/*/Dom*.vue',
21
+ '../visual/*/Dom*.vue',
22
+ '../designer/*/Dom*.vue',
23
+ '!../components/playground/DomRenderer.vue',
24
+ '!../components/toast/DomToastItem.vue',
25
+ '!../forms/_shared/Dom*.vue',
26
+ ], { import: 'default', query: '?raw' });
27
+
28
+ /**
29
+ * Resolves a component implementation or source only when documentation needs it.
30
+ * Keeping these import maps in a separate chunk avoids shipping every component's
31
+ * preload dependency list with the homepage router.
32
+ *
33
+ * @param {string} path Catalogued component path.
34
+ * @param {boolean} source Whether to load raw SFC source instead of the component.
35
+ * @returns {Promise<object|string>} Component or source module content.
36
+ */
37
+ export function loadComponent(path, source = false) {
38
+ const loader = (source ? lazyComponentSourceModules : lazyComponentModules)[path];
39
+ if (!loader) throw new Error(`Unknown component path: ${path}`);
40
+ return loader();
41
+ }
@@ -1,29 +1,19 @@
1
1
  import { componentCatalog } from './componentCatalog.generated.js';
2
2
 
3
- const lazyComponentModules = import.meta.glob([
4
- '../components/*/Dom*.vue',
5
- '../data/*/Dom*.vue',
6
- '../dev/*/Dom*.vue',
7
- '../forms/*/Dom*.vue',
8
- '../mobile/*/Dom*.vue',
9
- '../visual/*/Dom*.vue',
10
- '!../components/playground/DomRenderer.vue',
11
- '!../components/toast/DomToastItem.vue',
12
- '!../forms/_shared/Dom*.vue',
13
- ], { import: 'default' });
14
- const lazyComponentSourceModules = import.meta.glob([
15
- '../components/*/Dom*.vue',
16
- '../data/*/Dom*.vue',
17
- '../dev/*/Dom*.vue',
18
- '../forms/*/Dom*.vue',
19
- '../mobile/*/Dom*.vue',
20
- '../visual/*/Dom*.vue',
21
- '!../components/playground/DomRenderer.vue',
22
- '!../components/toast/DomToastItem.vue',
23
- '!../forms/_shared/Dom*.vue',
24
- ], { import: 'default', query: '?raw' });
3
+ export const componentSections = ['components', 'data', 'charts', 'dev', 'forms', 'mobile', 'visual', 'designer'];
4
+ export const componentNavigationSections = ['ai', ...componentSections];
25
5
 
26
- export const componentSections = ['components', 'data', 'dev', 'forms', 'mobile', 'visual'];
6
+ /**
7
+ * Resolves a component's navigation category independently of its source path.
8
+ * Unknown overrides fall back to the source section so components stay discoverable.
9
+ *
10
+ * @param {object} record Component catalogue or inspected metadata record.
11
+ * @returns {string} Section used by navigation, search, and component indexes.
12
+ */
13
+ export function getComponentNavigationSection(record) {
14
+ const section = record.doc?.nav?.section;
15
+ return componentNavigationSections.includes(section) ? section : record.section;
16
+ }
27
17
 
28
18
  /**
29
19
  * Returns component records that are safe for app-shell navigation and search.
@@ -47,10 +37,28 @@ export function getLazyComponentRecords() {
47
37
  return getComponentRecords()
48
38
  .map((record) => ({
49
39
  ...record,
50
- loader: lazyComponentModules[record.path],
51
- sourceLoader: lazyComponentSourceModules[record.path],
52
- }))
53
- .filter((record) => record.loader);
40
+ loader: createComponentLoader(record.path),
41
+ sourceLoader: createComponentLoader(record.path, true),
42
+ }));
43
+ }
44
+
45
+ /**
46
+ * Creates a documentation loader without importing the full component import map.
47
+ *
48
+ * @param {string} path Catalogued component path.
49
+ * @param {boolean} source Whether to load raw SFC source.
50
+ * @returns {Function} Lazy Vue Router or documentation source loader.
51
+ */
52
+ function createComponentLoader(path, source = false) {
53
+ /**
54
+ * Resolves the shared import map and then the requested component resource.
55
+ * @returns {Promise<object|string>} Component implementation or raw source.
56
+ */
57
+ async function load() {
58
+ const { loadComponent } = await import('./componentLoaders.js');
59
+ return loadComponent(path, source);
60
+ }
61
+ return load;
54
62
  }
55
63
 
56
64
  /**
@@ -113,8 +121,11 @@ export function getDevRedirectRecords() {
113
121
  */
114
122
  export function labelFromSection(section) {
115
123
  return {
124
+ ai: 'AI',
125
+ designer: 'UI designer',
116
126
  components: 'Components',
117
127
  data: 'Data',
128
+ charts: 'Data Visualisation',
118
129
  dev: 'Dev',
119
130
  forms: 'Forms',
120
131
  mobile: 'Mobile',
@@ -1,10 +1,12 @@
1
1
  const eagerComponentModules = import.meta.glob([
2
2
  '../components/*/Dom*.vue',
3
3
  '../data/*/Dom*.vue',
4
+ '../charts/*/Dom*.vue',
4
5
  '../dev/*/Dom*.vue',
5
6
  '../forms/*/Dom*.vue',
6
7
  '../mobile/*/Dom*.vue',
7
8
  '../visual/*/Dom*.vue',
9
+ '../designer/*/Dom*.vue',
8
10
  '!../components/playground/DomRenderer.vue',
9
11
  '!../components/toast/DomToastItem.vue',
10
12
  '!../forms/_shared/Dom*.vue',
@@ -33,7 +35,7 @@ export function getRuntimeComponentRecords() {
33
35
  * @returns {object|null} Component record, or null for unrecognized paths.
34
36
  */
35
37
  function recordFromPath(path, component) {
36
- const match = path.match(/^\.\.\/(components|data|dev|forms|mobile|visual)\/([^/]+)\/(Dom[^/]+)\.vue$/);
38
+ const match = path.match(/^\.\.\/(components|data|charts|dev|forms|mobile|visual|designer)\/([^/]+)\/(Dom[^/]+)\.vue$/);
37
39
  if (!match) return null;
38
40
 
39
41
  const [, section, slug, exportName] = match;
@@ -0,0 +1,67 @@
1
+ <script setup>
2
+ import { useId } from 'vue';
3
+ import DomButton from '../../components/button/DomButton.vue';
4
+ import DomEmptyState from '../../visual/empty-state/DomEmptyState.vue';
5
+ import DomSkeleton from '../../visual/skeleton/DomSkeleton.vue';
6
+
7
+ defineProps({
8
+ title: { type: String, default: 'Chart' },
9
+ description: { type: String, default: '' },
10
+ height: { type: Number, default: 280 },
11
+ loading: { type: Boolean, default: false },
12
+ error: { type: String, default: '' },
13
+ empty: { type: Boolean, default: false },
14
+ emptyLabel: { type: String, default: 'No data for this period' },
15
+ compact: { type: Boolean, default: false },
16
+ });
17
+ defineEmits(['retry']);
18
+ const titleId = useId();
19
+ </script>
20
+
21
+ <template>
22
+ <figure class="m-0 min-w-0 w-full" :aria-labelledby="titleId" :aria-busy="loading || undefined">
23
+ <figcaption :class="compact ? 'sr-only' : 'mb-5'">
24
+ <div :id="titleId" class="text-sm font-semibold text-canvas-fg">{{ title }}</div>
25
+ <p v-if="description" class="mt-1 text-sm leading-6 text-muted-fg">{{ description }}</p>
26
+ </figcaption>
27
+ <div class="relative min-w-0" :style="{ minHeight: `${Math.max(32, height)}px` }">
28
+ <div v-if="loading" role="status" class="flex h-full items-end gap-3 p-4" :style="{ height: `${Math.max(32, height)}px` }">
29
+ <span class="sr-only">Loading {{ title }}</span>
30
+ <DomSkeleton v-for="size in [45, 70, 55, 85, 65, 95, 75]" :key="size" aria-hidden="true" class="flex-1" :style="{ height: `${size}%` }" />
31
+ </div>
32
+ <div v-else-if="error" role="alert" class="grid place-content-center gap-3 text-center" :style="{ minHeight: `${Math.max(80, height)}px` }">
33
+ <p class="text-sm text-muted-fg">{{ error }}</p>
34
+ <DomButton size="sm" variant="secondary" class="justify-self-center" @click="$emit('retry')">Try again</DomButton>
35
+ </div>
36
+ <div v-else-if="empty" class="grid place-content-center" :style="{ minHeight: `${Math.max(32, height)}px` }">
37
+ <p v-if="compact" class="text-xs text-muted-fg">{{ emptyLabel }}</p>
38
+ <DomEmptyState v-else :title="emptyLabel" description="Choose another period or check back when measurements are available." size="sm" />
39
+ </div>
40
+ <slot v-else />
41
+ </div>
42
+ <slot name="legend" />
43
+ <slot name="table" />
44
+ </figure>
45
+ </template>
46
+
47
+ <style scoped>
48
+ /* Unovis draws SVG attributes from inherited variables, including in dark themes. */
49
+ figure {
50
+ --vis-font-family: inherit;
51
+ --vis-color-main: var(--primary);
52
+ --vis-axis-tick-label-color: var(--muted-fg);
53
+ --vis-axis-label-color: var(--muted-fg);
54
+ --vis-axis-grid-color: var(--border);
55
+ --vis-axis-domain-color: var(--border);
56
+ --vis-crosshair-line-stroke-color: var(--muted-fg);
57
+ --vis-donut-central-label-text-color: var(--canvas-fg);
58
+ --vis-donut-central-sub-label-text-color: var(--muted-fg);
59
+ --vis-donut-background-color: var(--secondary);
60
+ }
61
+
62
+ @media (prefers-reduced-motion: reduce) {
63
+ figure {
64
+ --vis-axis-tick-label-hide-transition: none;
65
+ }
66
+ }
67
+ </style>
@@ -0,0 +1,22 @@
1
+ <script setup>
2
+ defineProps({
3
+ items: { type: Array, default: () => [] },
4
+ hiddenKeys: { type: Array, default: () => [] },
5
+ });
6
+ defineEmits(['toggle']);
7
+ </script>
8
+
9
+ <template>
10
+ <div class="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-2" role="group" aria-label="Chart legend">
11
+ <button
12
+ v-for="item in items" :key="item.key" type="button"
13
+ class="inline-flex min-h-9 min-w-0 items-center gap-2 rounded-md px-1 text-xs text-muted-fg transition hover:text-canvas-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
14
+ :aria-pressed="!hiddenKeys.includes(item.key)"
15
+ :class="hiddenKeys.includes(item.key) && 'opacity-45'"
16
+ @click="$emit('toggle', item.key)"
17
+ >
18
+ <span class="h-0.5 w-4 shrink-0 border-t-2" :style="{ borderColor: item.color, borderStyle: item.dashed ? 'dashed' : 'solid' }" aria-hidden="true" />
19
+ <span :class="hiddenKeys.includes(item.key) && 'line-through'">{{ item.label }}</span>
20
+ </button>
21
+ </div>
22
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup>
2
+ defineProps({
3
+ label: { type: String, default: '' },
4
+ items: { type: Array, default: () => [] },
5
+ });
6
+ </script>
7
+
8
+ <template>
9
+ <div class="pointer-events-none max-w-full rounded-lg border border-border skin-popover px-3 py-2 text-xs shadow-lg">
10
+ <p class="mb-2 truncate font-medium text-canvas-fg">{{ label }}</p>
11
+ <dl class="space-y-1.5">
12
+ <div v-for="item in items" :key="item.key" class="flex items-center justify-between gap-5">
13
+ <dt class="flex min-w-0 items-center gap-2 text-muted-fg"><span class="size-2 shrink-0 rounded-sm" :style="{ background: item.color }" /><span class="truncate">{{ item.label }}</span></dt>
14
+ <dd class="shrink-0 font-medium tabular-nums text-canvas-fg">{{ item.value }}</dd>
15
+ </div>
16
+ </dl>
17
+ </div>
18
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ import VisSingleContainer from '@unovis/vue/containers/single-container';
3
+ import VisDonut, { VisDonutSelectors } from '@unovis/vue/components/donut';
4
+
5
+ defineProps({
6
+ items: { type: Array, required: true },
7
+ height: { type: Number, default: 260 },
8
+ total: { type: String, default: '' },
9
+ label: { type: String, default: 'Total' },
10
+ arcWidth: { type: Number, default: 36 },
11
+ });
12
+ const emit = defineEmits(['inspect', 'select']);
13
+ const events = { [VisDonutSelectors.segment]: { mouseover: inspect, mouseout: clear, click: select } };
14
+
15
+ /** @param {object} item Chart category. @returns {number} Segment value. */
16
+ function value(item) { return item.value; }
17
+ /** @param {object} item Chart category. @returns {string} Stable segment colour. */
18
+ function color(item) { return item.color; }
19
+ /** @param {object} item Chart category. @returns {string} Stable segment identity. */
20
+ function id(item) { return item.key; }
21
+ /** @param {object} datum Unovis arc datum. @returns {void} Shows category detail. */
22
+ function inspect(datum) { emit('inspect', datum.data || datum); }
23
+ /** @returns {void} Hides category detail. */
24
+ function clear() { emit('inspect', null); }
25
+ /** @param {object} datum Unovis arc datum. @returns {void} Selects the category. */
26
+ function select(datum) { emit('select', datum.data || datum); }
27
+ </script>
28
+
29
+ <template>
30
+ <VisSingleContainer :data="items" :height="height" :duration="0" class="block w-full">
31
+ <VisDonut :value="value" :color="color" :id="id" :arc-width="arcWidth" :pad-angle="0.025" :corner-radius="3" :central-label="total" :central-sub-label="label" :events="events" :duration="0" />
32
+ </VisSingleContainer>
33
+ </template>
@@ -0,0 +1,140 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
3
+ import { chartProps } from './chartProps.js';
4
+ import { chartPoints, chartSeries, formatChartValue, percentagePoints } from './chartUtils.js';
5
+ import ChartContainer from './ChartContainer.vue';
6
+ import ChartLegend from './ChartLegend.vue';
7
+ import ChartTooltip from './ChartTooltip.vue';
8
+
9
+ const props = defineProps({
10
+ ...chartProps,
11
+ kind: { type: String, default: 'line' },
12
+ stacked: { type: Boolean, default: false },
13
+ percentage: { type: Boolean, default: false },
14
+ horizontal: { type: Boolean, default: false },
15
+ compact: { type: Boolean, default: false },
16
+ });
17
+ const emit = defineEmits(['retry', 'select']);
18
+ const renderer = shallowRef(null);
19
+ const loadError = ref('');
20
+ const hiddenKeys = ref([]);
21
+ const inspected = ref(null);
22
+ let disposed = false;
23
+ const allSeries = computed(() => chartSeries(props.series));
24
+ const visibleSeries = computed(() => allSeries.value.filter((item) => !hiddenKeys.value.includes(item.key)));
25
+ const points = computed(() => chartPoints(props.rows, props.xKey, props.xType, allSeries.value));
26
+ const renderPoints = computed(() => props.percentage ? percentagePoints(points.value, visibleSeries.value) : points.value);
27
+ const hasData = computed(() => points.value.some((point) => visibleSeries.value.some((item) => Number.isFinite(point.values[item.key]))));
28
+ const validationError = computed(() => props.percentage && points.value.some((point) => visibleSeries.value.some((item) => point.values[item.key] < 0)) ? 'Percentage stacks require non-negative measurements.' : '');
29
+ const tooltipItems = computed(() => inspected.value ? visibleSeries.value.map((item) => ({ ...item, value: formatValue(inspected.value.values[item.key]) })) : []);
30
+ const safeHeight = computed(() => Math.max(props.compact ? 32 : 120, props.height));
31
+
32
+ onMounted(loadRenderer);
33
+ onBeforeUnmount(() => { disposed = true; });
34
+ watch(points, () => { inspected.value = null; });
35
+
36
+ /** Loads the drawing engine only in the browser, preserving SSR-safe imports. @returns {Promise<void>} */
37
+ async function loadRenderer() {
38
+ loadError.value = '';
39
+ try {
40
+ const module = await import('./XYRenderer.vue');
41
+ if (!disposed) renderer.value = module.default;
42
+ } catch {
43
+ if (!disposed) loadError.value = 'The chart could not be loaded.';
44
+ }
45
+ }
46
+
47
+ /** Retries an engine failure or delegates application data recovery. @returns {void} */
48
+ function retry() {
49
+ if (loadError.value) loadRenderer();
50
+ else emit('retry');
51
+ }
52
+
53
+ /** @param {string} key Series key. @returns {void} Toggles a series while preserving at least one. */
54
+ function toggleSeries(key) {
55
+ if (hiddenKeys.value.includes(key)) hiddenKeys.value = hiddenKeys.value.filter((entry) => entry !== key);
56
+ else if (visibleSeries.value.length > 1) hiddenKeys.value = [...hiddenKeys.value, key];
57
+ }
58
+
59
+ /** @param {unknown} value Measurement. @returns {string} Formatted measurement. */
60
+ function formatValue(value) { return formatChartValue(value, props); }
61
+
62
+ /** @param {number} value Numeric axis tick. @returns {string} Value-axis label. */
63
+ function formatAxisValue(value) {
64
+ if (props.yFormatter) return String(props.yFormatter(value));
65
+ return props.percentage ? `${Math.round(value)}%` : formatValue(value);
66
+ }
67
+
68
+ /** @param {unknown} value Raw horizontal value. @returns {string} Category, date, or number label. */
69
+ function formatLabel(value) {
70
+ if (props.xFormatter) return String(props.xFormatter(value));
71
+ if (props.xType === 'time') return new Intl.DateTimeFormat(props.locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(value));
72
+ return String(value ?? '—');
73
+ }
74
+
75
+ /** @param {number} value Numeric axis coordinate. @returns {string} Axis tick label. */
76
+ function formatAxisLabel(value) {
77
+ return formatLabel(props.xType === 'category' ? props.rows[Math.round(value)]?.[props.xKey] : value);
78
+ }
79
+
80
+ /** @param {object|null} point Hovered point. @returns {void} Updates the shared tooltip. */
81
+ function inspect(point) { inspected.value = point ? points.value.find((entry) => entry.index === point.index) || null : null; }
82
+
83
+ /** @param {KeyboardEvent} event Chart navigation key. @returns {void} Inspects or selects a datum. */
84
+ function navigate(event) {
85
+ if (!hasData.value) return;
86
+ const index = Math.max(0, points.value.findIndex((point) => point.index === inspected.value?.index));
87
+ let next = index;
88
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = Math.min(points.value.length - 1, index + 1);
89
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = Math.max(0, index - 1);
90
+ else if (event.key === 'Home') next = 0;
91
+ else if (event.key === 'End') next = points.value.length - 1;
92
+ else if (event.key === 'Escape') { inspected.value = null; return; }
93
+ else if (event.key === 'Enter' || event.key === ' ') { selectPoint(); event.preventDefault(); return; }
94
+ else return;
95
+ event.preventDefault();
96
+ inspected.value = points.value[next];
97
+ }
98
+
99
+ /** @returns {void} Emits the original application record selected by the user. */
100
+ function selectPoint() {
101
+ if (inspected.value) emit('select', { row: inspected.value.row, index: inspected.value.index });
102
+ }
103
+
104
+ /** @param {Event} event Accessible scrubber input. @returns {void} Inspects a data point on touch or keyboard. */
105
+ function scrub(event) { inspected.value = points.value[Number(event.target.value)]; }
106
+ </script>
107
+
108
+ <template>
109
+ <ChartContainer :title="title" :description="description" :height="safeHeight" :compact="compact" :loading="loading" :error="error || validationError || loadError" :empty="!hasData" :empty-label="emptyLabel" @retry="retry">
110
+ <div
111
+ class="relative rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
112
+ :style="{ height: `${safeHeight}px` }" tabindex="0" role="group"
113
+ :aria-label="`${title}. Use arrow keys to inspect values; Enter selects a row.`"
114
+ @keydown="navigate" @focus="inspect(points[0])" @blur="inspect(null)" @mouseleave="inspect(null)" @click="selectPoint"
115
+ >
116
+ <div aria-hidden="true">
117
+ <component :is="renderer" v-if="renderer" :key="`${kind}-${horizontal}-${stacked}-${percentage}`" :points="renderPoints" :series="visibleSeries" :kind="kind" :height="safeHeight" :curve="curve" :stacked="stacked || percentage" :percentage="percentage" :horizontal="horizontal" :compact="compact" :grid="grid" :x-type="xType" :format-x="formatAxisLabel" :format-y="formatAxisValue" @inspect="inspect" />
118
+ <div v-else class="h-full w-full" />
119
+ </div>
120
+ <ChartTooltip v-if="inspected && !compact" class="absolute right-2 top-2 z-10" :label="formatLabel(inspected.row[xKey])" :items="tooltipItems" />
121
+ <span class="sr-only" aria-live="polite">{{ inspected ? `${formatLabel(inspected.row[xKey])}: ${tooltipItems.map(item => `${item.label} ${item.value}`).join(', ')}` : '' }}</span>
122
+ </div>
123
+ <template #legend>
124
+ <ChartLegend v-if="legend && !loading && !error && allSeries.length" :items="allSeries" :hidden-keys="hiddenKeys" @toggle="toggleSeries" />
125
+ </template>
126
+ <template #table>
127
+ <details v-if="showTable && !loading && !error && points.length" class="mt-3 text-xs text-muted-fg">
128
+ <summary class="w-fit cursor-pointer rounded py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">View data for {{ title }}</summary>
129
+ <label v-if="points.length > 1" class="mt-3 flex items-center gap-3">Inspect data<input type="range" min="0" :max="points.length - 1" :value="Math.max(0, points.findIndex(point => point.index === inspected?.index))" class="min-w-0 flex-1 accent-primary" @input="scrub" /></label>
130
+ <div class="mt-3 max-h-80 overflow-auto rounded-lg border border-border" tabindex="0" :aria-label="`${title} data table`">
131
+ <table class="w-full text-left text-xs tabular-nums">
132
+ <caption class="sr-only">{{ title }}. Missing measurements are shown as a dash.{{ percentage ? ' Original measurements before percentage normalization.' : '' }}</caption>
133
+ <thead class="sticky top-0 bg-secondary text-canvas-fg"><tr><th scope="col" class="px-3 py-2">{{ xKey }}</th><th v-for="item in allSeries" :key="item.key" scope="col" class="px-3 py-2 text-right">{{ item.label }}</th></tr></thead>
134
+ <tbody><tr v-for="point in points" :key="point.index" class="border-t border-border"><th scope="row" class="whitespace-nowrap px-3 py-2 font-medium">{{ formatLabel(point.row[xKey]) }}</th><td v-for="item in allSeries" :key="item.key" class="whitespace-nowrap px-3 py-2 text-right">{{ formatValue(point.values[item.key]) }}</td></tr></tbody>
135
+ </table>
136
+ </div>
137
+ </details>
138
+ </template>
139
+ </ChartContainer>
140
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import VisXYContainer from '@unovis/vue/containers/xy-container';
4
+ import VisAxis from '@unovis/vue/components/axis';
5
+ import VisLine from '@unovis/vue/components/line';
6
+ import VisArea from '@unovis/vue/components/area';
7
+ import VisGroupedBar, { VisGroupedBarSelectors } from '@unovis/vue/components/grouped-bar';
8
+ import VisStackedBar, { VisStackedBarSelectors } from '@unovis/vue/components/stacked-bar';
9
+ import VisCrosshair from '@unovis/vue/components/crosshair';
10
+ import VisScatter from '@unovis/vue/components/scatter';
11
+
12
+ const props = defineProps({
13
+ points: { type: Array, required: true },
14
+ series: { type: Array, required: true },
15
+ kind: { type: String, default: 'line' },
16
+ height: { type: Number, default: 280 },
17
+ curve: { type: String, default: 'linear' },
18
+ stacked: { type: Boolean, default: false },
19
+ percentage: { type: Boolean, default: false },
20
+ horizontal: { type: Boolean, default: false },
21
+ compact: { type: Boolean, default: false },
22
+ grid: { type: Boolean, default: true },
23
+ xType: { type: String, default: 'category' },
24
+ formatX: { type: Function, required: true },
25
+ formatY: { type: Function, required: true },
26
+ });
27
+ const emit = defineEmits(['inspect']);
28
+ const accessors = computed(getAccessors);
29
+ const categoryTicks = computed(getCategoryTicks);
30
+ const valueDomain = computed(getValueDomain);
31
+ const barEvents = computed(() => ({
32
+ [props.stacked ? VisStackedBarSelectors.bar : VisGroupedBarSelectors.bar]: { mouseover: inspectBar, click: inspectBar },
33
+ }));
34
+
35
+ /** @returns {Array<Function>} Measurement accessors for visible series. */
36
+ function getAccessors() {
37
+ return props.series.map((item) => (point) => point.values[item.key]);
38
+ }
39
+
40
+ /** @returns {number[]|undefined} Exact categorical ticks; continuous axes choose their own. */
41
+ function getCategoryTicks() {
42
+ return props.xType === 'category' ? props.points.map(getX) : undefined;
43
+ }
44
+
45
+ /** @returns {Array<number|undefined>} Value domain including zero for filled charts. */
46
+ function getValueDomain() {
47
+ if (props.percentage) return [0, 100];
48
+ if (props.kind === 'line') return [undefined, undefined];
49
+ const values = props.points.flatMap((point) => props.series.map((item) => point.values[item.key])).filter(Number.isFinite);
50
+ return [values.some((value) => value < 0) ? undefined : 0, values.some((value) => value > 0) ? undefined : 0];
51
+ }
52
+
53
+ /** @param {object} point Projected datum. @returns {number} Category coordinate. */
54
+ function getX(point) { return point.x; }
55
+
56
+ /** @param {unknown} _datum Unovis datum. @param {number} index Series index. @returns {string} Stable colour. */
57
+ function getColor(_datum, index) { return props.series[index]?.color || 'var(--primary)'; }
58
+
59
+ /** @param {number} _x Pointer coordinate. @param {object} point Nearest datum. @returns {void} */
60
+ function inspectPoint(_x, point) { emit('inspect', point || null); }
61
+
62
+ /** @param {object} datum Bar or stack datum. @returns {void} Inspects horizontal and touch-selected bars. */
63
+ function inspectBar(datum) { emit('inspect', datum.datum || datum); }
64
+ </script>
65
+
66
+ <template>
67
+ <VisXYContainer
68
+ :data="points" :height="height" :duration="0" class="block w-full"
69
+ :x-domain="horizontal ? valueDomain : undefined" :y-domain="horizontal ? undefined : valueDomain"
70
+ :y-direction="horizontal ? 'south' : 'north'"
71
+ :margin="compact ? { top: 4, bottom: 4, left: 4, right: 4 } : { top: 12, right: 12 }"
72
+ >
73
+ <template v-if="kind === 'line'">
74
+ <VisLine v-for="(item, index) in series" :key="item.key" :x="getX" :y="accessors[index]" :color="item.color" :curve-type="curve" :line-width="2" :line-dash-array="item.dashed ? [5, 4] : undefined" :duration="0" />
75
+ <VisScatter v-if="points.length === 1" :x="getX" :y="accessors" :color="getColor" :size="24" :duration="0" />
76
+ </template>
77
+ <template v-else-if="kind === 'area'">
78
+ <VisArea v-if="stacked" :x="getX" :y="accessors" :color="getColor" :curve-type="curve" :opacity="0.45" :duration="0" />
79
+ <VisArea v-for="(item, index) in stacked ? [] : series" :key="item.key" :x="getX" :y="accessors[index]" :color="item.color" :curve-type="curve" :opacity="0.15" :line="true" :line-color="item.color" :duration="0" />
80
+ </template>
81
+ <component
82
+ :is="stacked ? VisStackedBar : VisGroupedBar" v-else
83
+ :x="getX" :y="accessors" :color="getColor" :orientation="horizontal ? 'horizontal' : 'vertical'"
84
+ :bar-padding="stacked ? 0.3 : 0.1" :group-padding="0.3" :rounded-corners="3" :bar-min-height="0" :duration="0" :events="barEvents"
85
+ />
86
+ <VisAxis v-if="!compact" :key="`category-${horizontal}`" :type="horizontal ? 'y' : 'x'" :tick-values="categoryTicks" :tick-format="formatX" :grid-line="false" :domain-line="false" :tick-line="false" :tick-text-width="horizontal ? 90 : 72" :tick-text-hide-overlapping="true" :tick-text-adaptive-sets="true" :duration="0" />
87
+ <VisAxis v-if="!compact" :key="`value-${horizontal}`" :type="horizontal ? 'x' : 'y'" :tick-format="formatY" :grid-line="grid" :domain-line="false" :tick-line="false" :tick-text-width="90" :tick-text-hide-overlapping="true" :num-ticks="4" :duration="0" />
88
+ <VisCrosshair v-if="!horizontal" :x="getX" :y="stacked ? [] : accessors" :y-stacked="stacked ? accessors : undefined" :color="getColor" :hide-when-far-from-pointer="false" :on-crosshair-move="inspectPoint" :duration="0" />
89
+ </VisXYContainer>
90
+ </template>
@@ -0,0 +1,23 @@
1
+ /** Shared presentation and data contract for the Cartesian chart family. */
2
+ export const chartProps = {
3
+ rows: { type: Array, default: () => [], _edit: { description: 'Application-owned records. Missing measurements remain gaps; zero is valid.' } },
4
+ series: { type: Array, default: () => [], _edit: { description: 'Field keys or { key, label, color, dashed } definitions. Keep keys stable when filtering.' } },
5
+ xKey: { type: String, default: 'date', _edit: { description: 'Record field used for category or horizontal axis values.' } },
6
+ xType: { type: String, default: 'category', _edit: { options: ['category', 'time', 'number'], description: 'Category preserves row order; time and number use proportional, sorted coordinates.' } },
7
+ title: { type: String, default: 'Chart', _edit: { description: 'Visible title and accessible chart name.' } },
8
+ description: { type: String, default: '', _edit: { description: 'Supporting context, units, or reporting period.' } },
9
+ height: { type: Number, default: 280, _edit: { description: 'Plot height in pixels; width responds to its container.' } },
10
+ format: { type: String, default: 'number', _edit: { options: ['number', 'compact', 'currency', 'percent'], description: 'Value format. Percent expects fractions, such as 0.25 for 25%.' } },
11
+ currency: { type: String, default: 'GBP', _edit: { description: 'ISO currency code for currency formatting.' } },
12
+ locale: { type: String, default: 'en-GB', _edit: { description: 'Locale used for number and date labels.' } },
13
+ valueFormatter: { type: Function, default: null, _edit: { description: 'Optional (value) => string override shared by axes, tooltips, and tables.' } },
14
+ xFormatter: { type: Function, default: null, _edit: { description: 'Optional (value) => string override for category/date labels.' } },
15
+ yFormatter: { type: Function, default: null, _edit: { description: 'Optional (value) => string for value-axis ticks only. Tooltips and tables keep the full valueFormatter output.' } },
16
+ loading: { type: Boolean, default: false, _edit: { description: 'Show a loading state in the reserved plot space.' } },
17
+ error: { type: String, default: '', _edit: { description: 'Show an application-owned error with a retry action.' } },
18
+ emptyLabel: { type: String, default: 'No data for this period', _edit: { description: 'Message when no finite measurements are available.' } },
19
+ legend: { type: Boolean, default: true, _edit: { description: 'Show buttons to toggle series without changing their colours.' } },
20
+ showTable: { type: Boolean, default: true, _edit: { description: 'Offer an accessible, server-rendered table of the chart data.' } },
21
+ grid: { type: Boolean, default: true, _edit: { description: 'Show subtle value-axis grid lines.' } },
22
+ curve: { type: String, default: 'linear', _edit: { options: ['linear', 'monotoneX', 'step'], description: 'Line interpolation. Linear avoids implying unobserved smooth changes.' } },
23
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Converts a measurement without turning missing values into misleading zeros.
3
+ * @param {unknown} value Raw application value.
4
+ * @returns {number|undefined} Finite measurement, or a missing value.
5
+ */
6
+ export function chartNumber(value) {
7
+ if (!['number', 'string'].includes(typeof value) || (typeof value === 'string' && !value.trim())) return undefined;
8
+ const number = Number(value);
9
+ return Number.isFinite(number) ? number : undefined;
10
+ }
11
+
12
+ /**
13
+ * Resolves a stable palette colour from a series key or an explicit CSS colour.
14
+ * @param {string} key Stable series or category key.
15
+ * @param {string} [color] Semantic token or CSS colour.
16
+ * @returns {string} CSS colour expression.
17
+ */
18
+ export function chartColor(key, color) {
19
+ if (color) return /^(primary|secondary|muted-fg|success|warning|destructive|chart-[1-5])$/.test(color) ? `var(--${color})` : color;
20
+ let hash = 0;
21
+ for (const character of String(key)) hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
22
+ return `var(--chart-${hash % 5 + 1}, var(--primary))`;
23
+ }
24
+
25
+ /**
26
+ * Normalizes series definitions and removes ambiguous duplicate keys.
27
+ * @param {Array<string|object>} series Field keys or series definitions.
28
+ * @returns {Array<object>} Labelled, consistently coloured series.
29
+ */
30
+ export function chartSeries(series) {
31
+ const seen = new Set();
32
+ return series.flatMap((entry) => {
33
+ const item = typeof entry === 'string' ? { key: entry } : entry;
34
+ if (!item?.key || seen.has(item.key)) return [];
35
+ seen.add(item.key);
36
+ return [{ ...item, label: item.label || item.key, color: chartColor(item.key, item.color) }];
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Projects records into numeric chart coordinates while retaining original rows.
42
+ * Continuous axes are sorted without modifying the supplied collection.
43
+ * @param {Array<object>} rows Application-owned records.
44
+ * @param {string} xKey Category, number, or date field.
45
+ * @param {'category'|'number'|'time'} xType Horizontal axis interpretation.
46
+ * @param {Array<object>} series Normalized series definitions.
47
+ * @returns {Array<object>} Renderable points with original row indexes.
48
+ */
49
+ export function chartPoints(rows, xKey, xType, series) {
50
+ const points = rows.flatMap((row, index) => {
51
+ if (!row || typeof row !== 'object') return [];
52
+ const rawX = row[xKey];
53
+ const x = xType === 'time'
54
+ ? (rawX == null || rawX === '' ? NaN : new Date(rawX).getTime())
55
+ : xType === 'number' ? chartNumber(rawX) : index;
56
+ if (!Number.isFinite(x)) return [];
57
+ return [{ row, index, x, values: Object.fromEntries(series.map((item) => [item.key, chartNumber(row[item.key])])) }];
58
+ });
59
+ return xType === 'category' ? points : points.sort((a, b) => a.x - b.x);
60
+ }
61
+
62
+ /**
63
+ * Formats finite values consistently across axes, tooltips, and data tables.
64
+ * @param {unknown} value Raw value.
65
+ * @param {object} options Locale, format, currency, and optional custom formatter.
66
+ * @returns {string} Human-readable measurement.
67
+ */
68
+ export function formatChartValue(value, { locale = 'en-GB', format = 'number', currency = 'GBP', valueFormatter } = {}) {
69
+ const number = chartNumber(value);
70
+ if (number === undefined) return '—';
71
+ if (valueFormatter) return String(valueFormatter(number));
72
+ const options = format === 'currency'
73
+ ? { style: 'currency', currency, maximumFractionDigits: 0 }
74
+ : format === 'percent'
75
+ ? { style: 'percent', maximumFractionDigits: 1 }
76
+ : { maximumFractionDigits: 2, ...(format === 'compact' ? { notation: 'compact' } : {}) };
77
+ return new Intl.NumberFormat(locale, options).format(number);
78
+ }
79
+
80
+ /**
81
+ * Normalizes each non-negative row to percentages of its visible series total.
82
+ * Missing values remain missing, and an all-zero row remains zero.
83
+ * @param {Array<object>} points Chart points.
84
+ * @param {Array<object>} series Visible series.
85
+ * @returns {Array<object>} New points with values in the range 0–100.
86
+ */
87
+ export function percentagePoints(points, series) {
88
+ return points.map((point) => {
89
+ const total = series.reduce((sum, item) => sum + (point.values[item.key] || 0), 0);
90
+ return { ...point, values: Object.fromEntries(series.map((item) => {
91
+ const value = point.values[item.key];
92
+ return [item.key, value === undefined ? undefined : total ? value / total * 100 : 0];
93
+ })) };
94
+ });
95
+ }