@svgrid/grid 2.1.5 → 2.1.7

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 (94) hide show
  1. package/dist/SvCarousel.svelte +35 -6
  2. package/dist/SvCarousel.svelte.d.ts +3 -0
  3. package/dist/SvColorInput.svelte +0 -1
  4. package/dist/SvContextMenu.svelte +26 -6
  5. package/dist/SvDrawer.svelte +5 -3
  6. package/dist/SvGrid.controller.svelte.d.ts +85 -2
  7. package/dist/SvGrid.controller.svelte.js +494 -1
  8. package/dist/SvGrid.css +142 -0
  9. package/dist/SvGrid.svelte +49 -27
  10. package/dist/SvGrid.types.d.ts +78 -0
  11. package/dist/SvGridBoard.svelte +71 -13
  12. package/dist/SvGridChart.svelte +41 -7
  13. package/dist/SvGridChart.svelte.d.ts +4 -0
  14. package/dist/SvGridChartPanel.svelte +485 -0
  15. package/dist/SvGridChartPanel.svelte.d.ts +28 -0
  16. package/dist/SvGridDropdown.svelte +18 -2
  17. package/dist/SvGroupCell.svelte +5 -5
  18. package/dist/SvMenu.svelte +25 -2
  19. package/dist/SvMenu.svelte.d.ts +3 -0
  20. package/dist/SvMenuList.svelte +31 -9
  21. package/dist/SvMenuList.svelte.d.ts +9 -1
  22. package/dist/SvNumberInput.svelte +1 -1
  23. package/dist/SvOtpInput.svelte +3 -2
  24. package/dist/SvPopover.svelte +13 -1
  25. package/dist/SvRowGroupPanel.svelte +23 -0
  26. package/dist/SvScrollArea.svelte +13 -2
  27. package/dist/SvScrollArea.svelte.d.ts +2 -0
  28. package/dist/SvTooltip.svelte +27 -2
  29. package/dist/SvTooltip.svelte.d.ts +3 -2
  30. package/dist/SvTour.svelte +30 -5
  31. package/dist/SvTour.svelte.d.ts +3 -0
  32. package/dist/SvTree.svelte +7 -3
  33. package/dist/SvTreeSelect.svelte +16 -9
  34. package/dist/build-api.js +51 -0
  35. package/dist/cdn/svgrid.js +17394 -16052
  36. package/dist/cdn/svgrid.svelte-external.js +16784 -15644
  37. package/dist/chart-export.d.ts +21 -0
  38. package/dist/chart-export.js +52 -11
  39. package/dist/chart.d.ts +30 -0
  40. package/dist/chart.js +53 -13
  41. package/dist/createAutocomplete.svelte.d.ts +6 -0
  42. package/dist/createAutocomplete.svelte.js +10 -0
  43. package/dist/createCombobox.svelte.d.ts +1 -0
  44. package/dist/createCombobox.svelte.js +1 -0
  45. package/dist/createCountryInput.svelte.d.ts +5 -0
  46. package/dist/createCountryInput.svelte.js +11 -0
  47. package/dist/createDropdownList.svelte.d.ts +4 -0
  48. package/dist/createDropdownList.svelte.js +7 -0
  49. package/dist/createPopoverSelect.svelte.d.ts +5 -3
  50. package/dist/createPopoverSelect.svelte.js +5 -3
  51. package/dist/createTree.svelte.d.ts +1 -0
  52. package/dist/createTree.svelte.js +15 -2
  53. package/dist/index.d.ts +4 -2
  54. package/dist/index.js +3 -2
  55. package/dist/svgrid-wrapper.types.d.ts +58 -0
  56. package/package.json +1 -1
  57. package/src/SvCarousel.svelte +35 -6
  58. package/src/SvColorInput.svelte +0 -1
  59. package/src/SvContextMenu.svelte +26 -6
  60. package/src/SvDrawer.svelte +5 -3
  61. package/src/SvGrid.controller.svelte.ts +481 -0
  62. package/src/SvGrid.css +142 -0
  63. package/src/SvGrid.svelte +49 -27
  64. package/src/SvGrid.types.ts +83 -0
  65. package/src/SvGridBoard.svelte +71 -13
  66. package/src/SvGridChart.svelte +41 -7
  67. package/src/SvGridChartPanel.svelte +485 -0
  68. package/src/SvGridDropdown.svelte +18 -2
  69. package/src/SvGroupCell.svelte +5 -5
  70. package/src/SvMenu.svelte +25 -2
  71. package/src/SvMenuList.svelte +31 -9
  72. package/src/SvNumberInput.svelte +1 -1
  73. package/src/SvOtpInput.svelte +3 -2
  74. package/src/SvPopover.svelte +13 -1
  75. package/src/SvRowGroupPanel.svelte +23 -0
  76. package/src/SvScrollArea.svelte +13 -2
  77. package/src/SvTooltip.svelte +27 -2
  78. package/src/SvTour.svelte +30 -5
  79. package/src/SvTree.svelte +7 -3
  80. package/src/SvTreeSelect.svelte +16 -9
  81. package/src/build-api.ts +49 -0
  82. package/src/chart-export.ts +57 -0
  83. package/src/chart.ts +70 -13
  84. package/src/createAutocomplete.svelte.ts +11 -0
  85. package/src/createCombobox.svelte.ts +1 -0
  86. package/src/createCountryInput.svelte.ts +12 -0
  87. package/src/createDropdownList.svelte.ts +8 -0
  88. package/src/createPopoverSelect.svelte.ts +5 -3
  89. package/src/createTree.svelte.ts +15 -2
  90. package/src/index.ts +6 -0
  91. package/src/overlays.test.ts +5 -0
  92. package/src/svgrid-wrapper.types.ts +62 -0
  93. package/src/svgrid.charting.test.ts +527 -0
  94. package/src/test-setup.ts +22 -0
package/src/chart.ts CHANGED
@@ -106,6 +106,16 @@ export type ChartSpec = {
106
106
  height?: number
107
107
  /** Palette used when a series has no explicit `color`. */
108
108
  palette?: string[]
109
+ /** Per-category color overrides (by category label) - for pie / donut slice
110
+ * recolouring, where colour follows the category, not a series. */
111
+ categoryColors?: Record<string, string>
112
+ /** Number format for the value axis, tooltips, data labels and reference
113
+ * lines. Unset = the default compact `1.2k` / `1.2M` style. */
114
+ valueFormat?: ChartValueFormat
115
+ /** Grouped (nested) category axis: a parent tier spanning consecutive leaf
116
+ * categories (spans must sum to `categories.length`). Vertical category
117
+ * charts only (ignored for time / horizontal / pie). */
118
+ categoryGroups?: Array<{ label: string; span: number }>
109
119
  /** Stack bar / area series (per axis) instead of grouping them. */
110
120
  stacked?: boolean
111
121
  /** Stack to 100% (each category normalized to its total). Implies stacked. */
@@ -387,6 +397,9 @@ export type ChartGeometry = {
387
397
  y2Ticks: ChartAxisTick[]
388
398
  hasRightAxis: boolean
389
399
  xTicks: ChartCategoryTick[]
400
+ /** Grouped category axis parent tier: label + span extent (pixels). Empty
401
+ * unless `spec.categoryGroups` is set on a vertical category chart. */
402
+ categoryGroupTicks: Array<{ label: string; xCenter: number; x0: number; x1: number }>
390
403
  /** True when x labels are long/many and should be rotated. */
391
404
  xLabelRotated: boolean
392
405
  legend: ChartLegendItem[]
@@ -730,6 +743,25 @@ function fmtTick(n: number): string {
730
743
  return String(Math.round(n * 100) / 100)
731
744
  }
732
745
 
746
+ /** Value-axis / tooltip / label number format. */
747
+ export type ChartValueFormat = 'number' | 'currency' | 'percent' | 'compact'
748
+
749
+ /**
750
+ * Format a numeric value for display, honouring an optional `valueFormat`.
751
+ * Builds on the compact `1.2k` / `1.2M` base: currency prefixes `$` (sign
752
+ * outside), percent multiplies by 100 and suffixes `%`. Unset / `'number'` /
753
+ * `'compact'` = the plain compact form.
754
+ */
755
+ export function formatChartValue(n: number, format?: ChartValueFormat): string {
756
+ if (!Number.isFinite(n)) return ''
757
+ if (format === 'currency') return `${n < 0 ? '-' : ''}$${fmtTick(Math.abs(n))}`
758
+ if (format === 'percent') {
759
+ const p = n * 100
760
+ return `${Math.round(p * 10) / 10}%`
761
+ }
762
+ return fmtTick(n)
763
+ }
764
+
733
765
  const DAY = 86_400_000
734
766
  /** Nice date-tick timestamps across [min, max]. */
735
767
  function dateTicks(tMin: number, tMax: number): number[] {
@@ -833,6 +865,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
833
865
  y2Ticks: [],
834
866
  hasRightAxis: false,
835
867
  xTicks: [],
868
+ categoryGroupTicks: [],
836
869
  xLabelRotated: false,
837
870
  legend,
838
871
  donut: null,
@@ -945,7 +978,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
945
978
  x: round(padL + slotW * i + slotW / 2),
946
979
  }))
947
980
  const yTicks: ChartAxisTick[] = dom.ticks.map((value) => ({
948
- value, y: yOfW(value), label: fmtTick(value),
981
+ value, y: yOfW(value), label: formatChartValue(value, spec.valueFormat),
949
982
  }))
950
983
  return {
951
984
  ...empty,
@@ -1132,7 +1165,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1132
1165
  const legend = Array.from({ length: 5 }, (_, i) => {
1133
1166
  const t = i / 4
1134
1167
  const value = vMin + (vMax - vMin) * t
1135
- return { value, color: colorAt(value), label: fmtTick(value) }
1168
+ return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
1136
1169
  })
1137
1170
  return {
1138
1171
  ...empty,
@@ -1486,7 +1519,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1486
1519
  const heatmapLegend = Array.from({ length: 5 }, (_, i) => {
1487
1520
  const t = i / 4
1488
1521
  const value = vMin + (vMax - vMin) * t
1489
- return { value, color: colorAt(value), label: fmtTick(value) }
1522
+ return { value, color: colorAt(value), label: formatChartValue(value, spec.valueFormat) }
1490
1523
  })
1491
1524
  return {
1492
1525
  ...empty,
@@ -1537,10 +1570,11 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1537
1570
  } else {
1538
1571
  path = `M${round(cx)},${round(cy)} L${round(ox0)},${round(oy0)} A${r},${r} 0 ${large} 1 ${round(ox1)},${round(oy1)} Z`
1539
1572
  }
1573
+ const catLabel = spec.categories[i] ?? String(i)
1540
1574
  return {
1541
1575
  path,
1542
- color: palette[i % palette.length]!,
1543
- label: spec.categories[i] ?? String(i),
1576
+ color: spec.categoryColors?.[catLabel] ?? palette[i % palette.length]!,
1577
+ label: catLabel,
1544
1578
  value: v,
1545
1579
  percent: frac * 100,
1546
1580
  cx: round(cx + labelR * Math.cos(mid)),
@@ -1550,7 +1584,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1550
1584
  return {
1551
1585
  ...empty,
1552
1586
  slices,
1553
- legend: spec.categories.map((label, i) => ({ label, color: palette[i % palette.length]! })),
1587
+ legend: spec.categories.map((label, i) => ({ label, color: spec.categoryColors?.[label] ?? palette[i % palette.length]! })),
1554
1588
  donut: ir > 0 ? { cx: round(cx), cy: round(cy), r: round(ir), total: s.values.reduce((a, b) => a + Math.max(0, b), 0) } : null,
1555
1589
  }
1556
1590
  }
@@ -1606,7 +1640,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1606
1640
  }
1607
1641
  const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => ({
1608
1642
  y: yOf(ref.value),
1609
- label: ref.label ?? fmtTick(ref.value),
1643
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
1610
1644
  color: ref.color ?? '#ef4444',
1611
1645
  dashed: ref.dashed !== false,
1612
1646
  }))
@@ -1615,7 +1649,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1615
1649
  plot,
1616
1650
  scatterPoints,
1617
1651
  referenceLines,
1618
- yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: fmtTick(value) })),
1652
+ yTicks: yDom.ticks.map((value) => ({ value, y: yOf(value), label: formatChartValue(value, spec.valueFormat) })),
1619
1653
  xTicks: xDom.ticks.map((value) => ({ label: fmtTick(value), x: xOf(value) })),
1620
1654
  }
1621
1655
  }
@@ -1702,7 +1736,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1702
1736
  }
1703
1737
 
1704
1738
  const valueTicks: ChartCategoryTick[] = dom.ticks.map((value) => ({
1705
- label: spec.stacked100 ? `${fmtTick(value)}%` : fmtTick(value),
1739
+ label: spec.stacked100 ? `${fmtTick(value)}%` : formatChartValue(value, spec.valueFormat),
1706
1740
  x: xOf(value),
1707
1741
  }))
1708
1742
  const catTicks: ChartAxisTick[] = spec.categories.map((label, i) => ({
@@ -1712,7 +1746,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1712
1746
  }))
1713
1747
  const referenceLinesV: ChartRefLineGeoV[] = (spec.referenceLines ?? []).map((ref) => ({
1714
1748
  x: xOf(ref.value),
1715
- label: ref.label ?? fmtTick(ref.value),
1749
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
1716
1750
  color: ref.color ?? '#ef4444',
1717
1751
  dashed: ref.dashed !== false,
1718
1752
  }))
@@ -1736,10 +1770,20 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1736
1770
 
1737
1771
  const maxLabel = spec.categories.reduce((m, c) => Math.max(m, c.length), 0)
1738
1772
  const xLabelRotated = spec.categories.length > 8 || maxLabel > 9
1773
+ // Grouped (nested) category axis: valid only when the spans cover every leaf.
1774
+ const validGroups =
1775
+ spec.categoryGroups &&
1776
+ spec.categoryGroups.length > 0 &&
1777
+ spec.xType !== 'time' &&
1778
+ spec.orientation !== 'horizontal' &&
1779
+ spec.categoryGroups.reduce((a, g) => a + g.span, 0) === spec.categories.length
1780
+ ? spec.categoryGroups
1781
+ : null
1782
+ const groupTierH = validGroups ? 18 : 0
1739
1783
  const padL = 48 + (spec.yAxisTitle ? 16 : 0)
1740
1784
  const padR = (hasRightAxis ? 48 : 12) + (spec.y2AxisTitle ? 16 : 0)
1741
1785
  const padT = 10
1742
- const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0)
1786
+ const padB = (xLabelRotated ? 54 : 28) + (spec.xAxisTitle ? 16 : 0) + groupTierH
1743
1787
  const plotW = Math.max(1, width - padL - padR)
1744
1788
  const plotH = Math.max(1, height - padT - padB)
1745
1789
  const plot = { x: padL, y: padT, w: plotW, h: plotH }
@@ -1790,6 +1834,18 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1790
1834
  }))
1791
1835
  : spec.categories.map((label, i) => ({ label, x: xCenter(i) }))
1792
1836
 
1837
+ // Parent-tier ticks for a grouped category axis: each spans its leaves.
1838
+ const categoryGroupTicks: ChartGeometry['categoryGroupTicks'] = []
1839
+ if (validGroups && !timeOk) {
1840
+ let start = 0
1841
+ for (const g of validGroups) {
1842
+ const x0 = round(padL + slot * start)
1843
+ const x1 = round(padL + slot * (start + g.span))
1844
+ categoryGroupTicks.push({ label: g.label, x0, x1, xCenter: round((x0 + x1) / 2) })
1845
+ start += g.span
1846
+ }
1847
+ }
1848
+
1793
1849
  const barSeries = series.filter((s) => s.kind === 'bar')
1794
1850
  const bars: ChartBar[] = []
1795
1851
  if (barSeries.length) {
@@ -1992,7 +2048,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
1992
2048
  }
1993
2049
 
1994
2050
  const tickFor = (dom: NiceScale, log: boolean): ChartAxisTick[] =>
1995
- dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: fmtTick(value) }))
2051
+ dom.ticks.map((value) => ({ value, y: yOf(dom, value, log), label: formatChartValue(value, spec.valueFormat) }))
1996
2052
 
1997
2053
  const referenceLines: ChartRefLineGeo[] = (spec.referenceLines ?? []).map((ref) => {
1998
2054
  const onRight = ref.axis === 'right'
@@ -2000,7 +2056,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
2000
2056
  const log = onRight ? rightLog : leftLog
2001
2057
  return {
2002
2058
  y: yOf(dom, ref.value, log),
2003
- label: ref.label ?? fmtTick(ref.value),
2059
+ label: ref.label ?? formatChartValue(ref.value, spec.valueFormat),
2004
2060
  color: ref.color ?? '#ef4444',
2005
2061
  dashed: ref.dashed !== false,
2006
2062
  }
@@ -2081,6 +2137,7 @@ export function buildChart(spec: ChartSpec, theme: 'light' | 'dark' = 'light'):
2081
2137
  y2Ticks: rightDom ? tickFor(rightDom, rightLog) : [],
2082
2138
  hasRightAxis,
2083
2139
  xTicks,
2140
+ categoryGroupTicks,
2084
2141
  xLabelRotated: timeOk ? false : xLabelRotated,
2085
2142
  referenceLines,
2086
2143
  overlays,
@@ -41,7 +41,12 @@ export type AutocompleteConfig = {
41
41
  hint?: () => string | undefined
42
42
  }
43
43
 
44
+ let uid = 0
45
+
44
46
  export function createAutocomplete(config: AutocompleteConfig) {
47
+ const id = `sv-ac-${uid++}`
48
+ const listId = `${id}-list`
49
+ const optionId = (index: number) => `${listId}-opt-${index}`
45
50
  const minChars = () => config.minChars?.() ?? 1
46
51
 
47
52
  const ariaState = (): EditorAriaState => ({
@@ -82,6 +87,8 @@ export function createAutocomplete(config: AutocompleteConfig) {
82
87
  get filtered() { return filtered },
83
88
  /** Controlled text value. */
84
89
  get value() { return config.value() },
90
+ /** id of the listbox element (matches inputProps `aria-controls`). */
91
+ listId,
85
92
  isActive: (i: number) => i === active,
86
93
  setActive: (i: number) => { active = i },
87
94
  maybeOpen,
@@ -94,7 +101,9 @@ export function createAutocomplete(config: AutocompleteConfig) {
94
101
  id: config.id?.(),
95
102
  role: 'combobox' as const,
96
103
  'aria-expanded': open,
104
+ 'aria-controls': listId,
97
105
  'aria-autocomplete': 'list' as const,
106
+ 'aria-activedescendant': open && filtered[active] ? optionId(active) : undefined,
98
107
  ...editorAria(ariaState()),
99
108
  value: config.value(),
100
109
  disabled: config.disabled?.() ?? false,
@@ -104,10 +113,12 @@ export function createAutocomplete(config: AutocompleteConfig) {
104
113
  }),
105
114
  /** Spread onto the listbox container. */
106
115
  listboxProps: () => ({
116
+ id: listId,
107
117
  role: 'listbox' as const,
108
118
  }),
109
119
  /** Spread onto the suggestion at `index` (index into `filtered`). */
110
120
  optionProps: (index: number) => ({
121
+ id: optionId(index),
111
122
  role: 'option' as const,
112
123
  tabindex: -1,
113
124
  'aria-selected': index === active,
@@ -138,6 +138,7 @@ export function createCombobox(config: ComboboxConfig) {
138
138
  'aria-expanded': open,
139
139
  'aria-controls': listId,
140
140
  'aria-autocomplete': 'list' as const,
141
+ 'aria-activedescendant': open && filtered[active] ? optionId(active) : undefined,
141
142
  ...editorAria(ariaState()),
142
143
  value: shownText,
143
144
  disabled: disabled(),
@@ -41,7 +41,12 @@ export type CountryInputConfig = {
41
41
  hint?: () => string | undefined
42
42
  }
43
43
 
44
+ let uid = 0
45
+
44
46
  export function createCountryInput(config: CountryInputConfig) {
47
+ const id = `sv-country-${uid++}`
48
+ const listId = `${id}-list`
49
+ const optionId = (i: number) => `${listId}-opt-${i}`
45
50
  const disabled = () => config.disabled?.() ?? false
46
51
 
47
52
  const ariaState = (): EditorAriaState => ({
@@ -75,6 +80,8 @@ export function createCountryInput(config: CountryInputConfig) {
75
80
  function pick(code: string) { config.onChange?.(code); open = false; config.focusTrigger?.() }
76
81
  function setQuery(v: string) { query = v; active = 0 }
77
82
  function onSearchInput(e: Event) { setQuery((e.currentTarget as HTMLInputElement).value) }
83
+ /** The current active-descendant id (for the focused search input), or undefined. */
84
+ const activeDescendant = () => (open && filtered.length > 0 ? optionId(active) : undefined)
78
85
  function onKeydown(e: KeyboardEvent) {
79
86
  if (!open) return
80
87
  if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
@@ -111,6 +118,7 @@ export function createCountryInput(config: CountryInputConfig) {
111
118
  type: 'button' as const,
112
119
  'aria-haspopup': 'listbox' as const,
113
120
  'aria-expanded': open,
121
+ 'aria-controls': listId,
114
122
  ...editorAria(ariaState()),
115
123
  disabled: disabled(),
116
124
  onclick: toggle,
@@ -120,17 +128,21 @@ export function createCountryInput(config: CountryInputConfig) {
120
128
  type: 'text' as const,
121
129
  value: query,
122
130
  'aria-label': config.searchLabel?.() ?? 'Search countries',
131
+ 'aria-controls': listId,
132
+ 'aria-activedescendant': activeDescendant(),
123
133
  oninput: onSearchInput,
124
134
  onkeydown: onKeydown,
125
135
  }),
126
136
  /** Spread onto the listbox container. */
127
137
  listboxProps: () => ({
138
+ id: listId,
128
139
  role: 'listbox' as const,
129
140
  }),
130
141
  /** Spread onto the country option at `index` (index into `filtered`). */
131
142
  optionProps: (index: number) => {
132
143
  const c = filtered[index]
133
144
  return {
145
+ id: optionId(index),
134
146
  role: 'option' as const,
135
147
  tabindex: -1,
136
148
  'aria-selected': c ? c.code === config.value() : false,
@@ -41,7 +41,11 @@ export type DropdownListConfig = {
41
41
  hint?: () => string | undefined
42
42
  }
43
43
 
44
+ let uid = 0
45
+
44
46
  export function createDropdownList(config: DropdownListConfig) {
47
+ const listId = `sv-ddl-${uid++}`
48
+ const optionId = (index: number) => `${listId}-opt-${index}`
45
49
  const opts = () => config.options()
46
50
  const disabled = () => config.disabled?.() ?? false
47
51
 
@@ -125,6 +129,8 @@ export function createDropdownList(config: DropdownListConfig) {
125
129
  type: 'button' as const,
126
130
  'aria-haspopup': 'listbox' as const,
127
131
  'aria-expanded': open,
132
+ 'aria-controls': listId,
133
+ 'aria-activedescendant': open && opts()[active] ? optionId(active) : undefined,
128
134
  ...editorAria(ariaState()),
129
135
  disabled: disabled(),
130
136
  onclick: toggle,
@@ -132,6 +138,7 @@ export function createDropdownList(config: DropdownListConfig) {
132
138
  }),
133
139
  /** Spread onto the listbox container. */
134
140
  listboxProps: () => ({
141
+ id: listId,
135
142
  role: 'listbox' as const,
136
143
  tabindex: -1,
137
144
  }),
@@ -140,6 +147,7 @@ export function createDropdownList(config: DropdownListConfig) {
140
147
  const o = opts()[index]
141
148
  return {
142
149
  role: 'option' as const,
150
+ id: optionId(index),
143
151
  tabindex: -1,
144
152
  'aria-selected': o ? o.value === config.value() : false,
145
153
  'aria-disabled': o?.disabled,
@@ -182,9 +182,11 @@ export function createPopoverSelect(config: PopoverSelectConfig) {
182
182
  onPanelKeydown,
183
183
  /** True when item `i` is the roving-active one. */
184
184
  isActive: (i: number) => i === active,
185
- /** Spread onto the trigger button. */
186
- triggerProps: () => ({
187
- 'aria-haspopup': 'listbox' as const,
185
+ /** Spread onto the trigger button. Pass the ARIA popup type the panel
186
+ * exposes (mirrors `focusOwnerProps(role)`); defaults to 'listbox' for
187
+ * the classic select panels. */
188
+ triggerProps: (haspopup: 'listbox' | 'tree' | 'grid' = 'listbox') => ({
189
+ 'aria-haspopup': haspopup,
188
190
  'aria-expanded': open,
189
191
  'aria-controls': open ? panelId : undefined,
190
192
  onkeydown: onTriggerKeydown,
@@ -231,6 +231,18 @@ export function createTree(config: TreeConfig) {
231
231
  config.onCheck?.([...next])
232
232
  }
233
233
 
234
+ // Step from `from` towards `dir` (+1/-1), skipping disabled rows, so roving
235
+ // focus never lands on a node with zero semantic affordance for AT users.
236
+ // Falls back to `from` if every row in that direction is disabled.
237
+ function stepEnabled(from: number, dir: 1 | -1): number {
238
+ let i = from
239
+ while (i + dir >= 0 && i + dir <= rows.length - 1) {
240
+ i += dir
241
+ if (!rows[i]?.node.disabled) return i
242
+ }
243
+ return from
244
+ }
245
+
234
246
  function onKeydown(e: KeyboardEvent) {
235
247
  const item = rows[active]
236
248
  if (!item) return
@@ -246,8 +258,8 @@ export function createTree(config: TreeConfig) {
246
258
  }
247
259
  const rtl = config.dir?.() === 'rtl'
248
260
  switch (e.key) {
249
- case 'ArrowDown': e.preventDefault(); setActive(Math.min(active + 1, rows.length - 1), true); break
250
- case 'ArrowUp': e.preventDefault(); setActive(Math.max(active - 1, 0), true); break
261
+ case 'ArrowDown': e.preventDefault(); setActive(stepEnabled(active, 1), true); break
262
+ case 'ArrowUp': e.preventDefault(); setActive(stepEnabled(active, -1), true); break
251
263
  case 'ArrowRight': e.preventDefault(); (rtl ? collapse : expand)(); break
252
264
  case 'ArrowLeft': e.preventDefault(); (rtl ? expand : collapse)(); break
253
265
  case 'Enter': e.preventDefault(); select(item.node); break
@@ -294,6 +306,7 @@ export function createTree(config: TreeConfig) {
294
306
  'aria-selected': row.node.id === selected(),
295
307
  'aria-expanded': row.hasChildren ? row.open : undefined,
296
308
  'aria-checked': ariaChecked,
309
+ 'aria-disabled': row.node.disabled || undefined,
297
310
  'data-row': row.index,
298
311
  tabindex: row.index === active ? 0 : -1,
299
312
  onclick: () => { setActive(row.index); select(row.node) },
package/src/index.ts CHANGED
@@ -286,8 +286,11 @@ export {
286
286
  export { default as FlexRender } from './FlexRender.svelte'
287
287
  export { renderComponent, renderSnippet } from './render-component'
288
288
  export { default as SvGridChart } from './SvGridChart.svelte'
289
+ export { default as SvGridChartPanel } from './SvGridChartPanel.svelte'
290
+ export type { ChartingConfig, ChartAggregateRequest, ChartAggregateBucket } from './SvGrid.types'
289
291
  export {
290
292
  buildChart,
293
+ formatChartValue,
291
294
  rowsToChartSpec,
292
295
  niceScale,
293
296
  niceLogScale,
@@ -326,6 +329,7 @@ export {
326
329
  type ChartGaugeLayout,
327
330
  type ChartSankeyNode,
328
331
  type ChartSankeyLink,
332
+ type ChartValueFormat,
329
333
  type TreeNode,
330
334
  } from './chart'
331
335
  export {
@@ -333,6 +337,8 @@ export {
333
337
  downloadChartSvg,
334
338
  chartToPngBlob,
335
339
  downloadChartPng,
340
+ chartSpecToCsv,
341
+ downloadChartCsv,
336
342
  type ChartExportOptions,
337
343
  } from './chart-export'
338
344
  export {
@@ -79,6 +79,11 @@ describe('SvTooltip', () => {
79
79
  expect(tip).not.toBeNull()
80
80
  expect(tip.getAttribute('role')).toBe('tooltip')
81
81
  expect(anchor.getAttribute('aria-describedby')).toBe(tip.id)
82
+ // The wrapper span is never itself focusable, so AT users only ever land
83
+ // on the real interactive child below - it must carry the description
84
+ // too, or it's never announced on focus (see SvTooltip.svelte effect).
85
+ const focusEl = target.querySelector<HTMLElement>('.t')!
86
+ expect(focusEl.getAttribute('aria-describedby')).toBe(tip.id)
82
87
  destroy()
83
88
  } finally { vi.useRealTimers() }
84
89
  })
@@ -1,5 +1,6 @@
1
1
  import type { CellFormatConfig, ColumnDef, RowData, SvGridOptions, TableFeatures } from './core'
2
2
  import type { GridExportOptions, GridClipboardOptions } from './export-format'
3
+ import type { ChartSpec, ChartType } from './chart'
3
4
 
4
5
  export type SvGridFilterOperator =
5
6
  | 'contains'
@@ -33,6 +34,32 @@ export type SvGridViewState = {
33
34
  >
34
35
  /** Facet (Excel-style value checklist) selections, keyed by column id. */
35
36
  facetFilters: Record<string, string[]>
37
+ /** Built-in chart panel state (present only when `charting` is on): the
38
+ * ACTIVE chart, for back-compat + spread-and-tweak. */
39
+ chart?: {
40
+ open: boolean
41
+ type: ChartType
42
+ dimension: string | null
43
+ series: string | null
44
+ measure: string | null
45
+ reduce: 'sum' | 'avg' | 'count'
46
+ stacked: boolean
47
+ }
48
+ /** All charts in the tab strip (multi-chart), plus the active index. */
49
+ charts?: Array<{
50
+ title: string
51
+ type: ChartType
52
+ dimension: string | null
53
+ series: string | null
54
+ measure: string | null
55
+ reduce: 'sum' | 'avg' | 'count'
56
+ stacked: boolean | null
57
+ dataLabels: boolean | null
58
+ logScale: boolean | null
59
+ timeAxis: boolean | null
60
+ valueFormat: 'number' | 'currency' | 'percent' | 'compact' | null
61
+ }>
62
+ chartActive?: number
36
63
  }
37
64
 
38
65
  /**
@@ -94,6 +121,41 @@ export type SvGridApi<
94
121
  */
95
122
  getSelected(): Array<[number, number, number, number]>
96
123
 
124
+ // ----- Integrated charting (requires the `charting` prop) -----
125
+ /** Open the built-in chart panel. */
126
+ openChart(): void
127
+ /** Close the built-in chart panel. */
128
+ closeChart(): void
129
+ /** The live chart spec the panel is rendering, or `null`. */
130
+ getChartSpec(): ChartSpec | null
131
+ /** Select cell rectangles + open the chart panel (scopes to the range). */
132
+ chartRange(ranges?: ReadonlyArray<readonly [number, number, number, number]>): void
133
+ /**
134
+ * Configure the built-in chart panel's ACTIVE chart. Column references
135
+ * accept a column id OR field name. Opens the panel unless `open === false`.
136
+ */
137
+ configureChart(config: {
138
+ open?: boolean
139
+ type?: ChartType
140
+ dimension?: string | null
141
+ series?: string | null
142
+ measure?: string | null
143
+ reduce?: 'sum' | 'avg' | 'count'
144
+ stacked?: boolean
145
+ dataLabels?: boolean
146
+ logScale?: boolean
147
+ timeAxis?: boolean
148
+ valueFormat?: 'number' | 'currency' | 'percent' | 'compact'
149
+ }): void
150
+ /**
151
+ * Register a natural-language "chart this" handler. When set, the chart
152
+ * panel shows an AI button. `@svgrid/enterprise`'s `enableAiCharting(api)`
153
+ * fills this. Pass `null` to remove it.
154
+ */
155
+ setChartAiHandler(
156
+ handler: ((prompt: string) => Promise<Record<string, unknown> | null>) | null,
157
+ ): void
158
+
97
159
  // ----- Rows -----
98
160
  /** Add one row. `position` defaults to `'bottom'`. */
99
161
  addRow(row: TData, position?: 'top' | 'bottom' | number): void