@svgrid/grid 2.1.6 → 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
@@ -21,19 +21,29 @@
21
21
  * Rendered inside a portalled panel by SvMenu; it self-imports for submenus.
22
22
  */
23
23
  import Self from './SvMenuList.svelte'
24
+ import { type EditorDir } from './editor-contract'
24
25
 
25
26
  let {
26
27
  items,
27
28
  onclose,
28
29
  onselect,
29
30
  submenu = false,
31
+ oncollapse,
32
+ dir,
30
33
  }: {
31
34
  items: ReadonlyArray<MenuItem>
32
- /** Close the WHOLE menu (a leaf was chosen or Escape at the root). */
35
+ /** Close the WHOLE menu (a leaf was chosen, Escape, or outside-click). */
33
36
  onclose: () => void
34
37
  /** Report the chosen leaf item. */
35
38
  onselect: (item: MenuItem) => void
36
39
  submenu?: boolean
40
+ /** Collapse just THIS submenu level (ArrowLeft, or ArrowRight under `rtl`)
41
+ * and refocus the item that opened it. Only meaningful when `submenu` is
42
+ * true; the root list has nowhere to collapse into. */
43
+ oncollapse?: () => void
44
+ /** Text direction; under `rtl` the ArrowRight/ArrowLeft submenu open/close
45
+ * keys swap (see createTree.svelte.ts for the reference pattern). */
46
+ dir?: EditorDir
37
47
  } = $props()
38
48
 
39
49
  // Indexes of real (non-separator) items, for roving focus.
@@ -61,20 +71,25 @@
61
71
  }
62
72
  function onKeydown(e: KeyboardEvent, i: number) {
63
73
  const item = items[i]
74
+ const rtl = dir === 'rtl'
75
+ // Open this item's submenu (opposite key under rtl).
76
+ const openThis = () => { if (item?.children?.length) { e.preventDefault(); openSub = i } }
77
+ // Collapse just THIS list back into its parent item (opposite key under
78
+ // rtl) - `oncollapse` is local to this nesting level, unlike `onclose`.
79
+ const collapseThis = () => { if (submenu) { e.preventDefault(); oncollapse?.() } }
64
80
  switch (e.key) {
65
81
  case 'ArrowDown': e.preventDefault(); move(1); break
66
82
  case 'ArrowUp': e.preventDefault(); move(-1); break
67
83
  case 'Home': e.preventDefault(); focusItem(focusable[0] ?? 0); break
68
84
  case 'End': e.preventDefault(); focusItem(focusable.at(-1) ?? 0); break
69
- case 'ArrowRight':
70
- if (item?.children?.length) { e.preventDefault(); openSub = i }
71
- break
72
- case 'ArrowLeft':
73
- if (submenu) { e.preventDefault(); onclose() } // parent listens via its own onclose wiring
74
- break
85
+ case 'ArrowRight': (rtl ? collapseThis : openThis)(); break
86
+ case 'ArrowLeft': (rtl ? openThis : collapseThis)(); break
75
87
  case 'Enter':
76
88
  case ' ': e.preventDefault(); if (item) choose(item); break
77
89
  case 'Escape': e.preventDefault(); onclose(); break
90
+ // Tab leaves the menu entirely (WAI-ARIA menu button pattern): close the
91
+ // whole tree rather than trapping focus inside a portalled panel.
92
+ case 'Tab': onclose(); break
78
93
  }
79
94
  }
80
95
  </script>
@@ -108,7 +123,14 @@
108
123
  </button>
109
124
  {#if item.children?.length && openSub === i}
110
125
  <div class="sv-menu__flyout">
111
- <Self items={item.children} onclose={onclose} onselect={onselect} submenu />
126
+ <Self
127
+ items={item.children}
128
+ {onclose}
129
+ {onselect}
130
+ oncollapse={() => { openSub = -1; focusItem(i) }}
131
+ {dir}
132
+ submenu
133
+ />
112
134
  </div>
113
135
  {/if}
114
136
  </div>
@@ -134,7 +156,7 @@
134
156
  .sv-menu__label { flex: 1; white-space: nowrap; }
135
157
  .sv-menu__shortcut { color: var(--sg-muted, #94a3b8); font-size: 11.5px; margin-inline-start: 12px; }
136
158
  .sv-menu__chev { color: var(--sg-muted, #94a3b8); flex: none; }
137
- .sv-menu[dir='rtl'] .sv-menu__chev, .sv-menu__list.is-submenu .sv-menu__chev { }
159
+ .sv-menu[dir='rtl'] .sv-menu__chev, .sv-menu__list.is-submenu .sv-menu__chev { transform: scaleX(-1); }
138
160
  .sv-menu__flyout {
139
161
  position: absolute; top: -5px; inset-inline-start: 100%; z-index: 1;
140
162
  background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0);
@@ -118,7 +118,7 @@
118
118
  .sv-num.is-disabled { opacity: 0.6; }
119
119
  .sv-num__input {
120
120
  flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit; font: inherit;
121
- text-align: right; padding: 0 8px;
121
+ text-align: end; padding: 0 8px;
122
122
  }
123
123
  .sv-num--sm { height: 28px; font-size: 12px; }
124
124
  .sv-num--md { height: 34px; font-size: 13px; }
@@ -77,6 +77,7 @@
77
77
  if (ch) focusCell(i + 1)
78
78
  }
79
79
  function onCellKeydown(i: number, e: KeyboardEvent) {
80
+ const rtl = dir === 'rtl'
80
81
  if (e.key === 'Backspace') {
81
82
  e.preventDefault()
82
83
  const arr = currentCells()
@@ -90,10 +91,10 @@
90
91
  }
91
92
  } else if (e.key === 'ArrowLeft') {
92
93
  e.preventDefault()
93
- focusCell(i - 1)
94
+ focusCell(rtl ? i + 1 : i - 1)
94
95
  } else if (e.key === 'ArrowRight') {
95
96
  e.preventDefault()
96
- focusCell(i + 1)
97
+ focusCell(rtl ? i - 1 : i + 1)
97
98
  }
98
99
  }
99
100
  function onPaste(i: number, e: ClipboardEvent) {
@@ -84,6 +84,14 @@
84
84
  let hoverTimer: ReturnType<typeof setTimeout> | undefined
85
85
  function onEnter() { if (trigger === 'hover') { clearTimeout(hoverTimer); setOpen(true) } }
86
86
  function onLeave() { if (trigger === 'hover') { hoverTimer = setTimeout(() => setOpen(false), 120) } }
87
+
88
+ /** Fallback accessible name for the panel when `ariaLabel` isn't given: the
89
+ * anchor's own aria-label, else its trimmed text content. */
90
+ function anchorFallbackLabel(): string | undefined {
91
+ const label = anchorEl?.getAttribute('aria-label')
92
+ if (label) return label
93
+ return anchorEl?.textContent?.trim() || undefined
94
+ }
87
95
  </script>
88
96
 
89
97
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -93,6 +101,8 @@
93
101
  onclick={() => trigger === 'click' && toggle()}
94
102
  onpointerenter={onEnter}
95
103
  onpointerleave={onLeave}
104
+ onfocusin={onEnter}
105
+ onfocusout={onLeave}
96
106
  >
97
107
  {@render anchor?.()}
98
108
  </span>
@@ -108,9 +118,11 @@
108
118
  style:left={`${rect.left}px`}
109
119
  style:min-width={minWidth ? `${rect.width}px` : undefined}
110
120
  role="dialog"
111
- aria-label={ariaLabel}
121
+ aria-label={ariaLabel ?? anchorFallbackLabel()}
112
122
  onpointerenter={onEnter}
113
123
  onpointerleave={onLeave}
124
+ onfocusin={onEnter}
125
+ onfocusout={onLeave}
114
126
  >
115
127
  {@render children?.()}
116
128
  </div>
@@ -48,6 +48,24 @@
48
48
  const id = e.dataTransfer?.getData('text/sv-column')
49
49
  if (id) add(id)
50
50
  }
51
+ /** Keyboard equivalent of dragging a chip: moves the chip at `i` one slot earlier/later,
52
+ * reusing `reorder()` (via `dragIndex`) so there is a single source of truth for the move. */
53
+ function moveByKeyboard(i: number, delta: number) {
54
+ const to = i + delta
55
+ if (to < 0 || to >= groupBy.length) return
56
+ dragIndex = i
57
+ reorder(to)
58
+ }
59
+ function onChipKeydown(e: KeyboardEvent, i: number) {
60
+ if (!e.altKey) return
61
+ if (e.key === 'ArrowLeft') {
62
+ e.preventDefault()
63
+ moveByKeyboard(i, -1)
64
+ } else if (e.key === 'ArrowRight') {
65
+ e.preventDefault()
66
+ moveByKeyboard(i, 1)
67
+ }
68
+ }
51
69
  </script>
52
70
 
53
71
  <div
@@ -63,14 +81,19 @@
63
81
  {:else}
64
82
  {#each groupBy as id, i (id)}
65
83
  {#if i > 0}<span class="sv-rgp-sep" aria-hidden="true">›</span>{/if}
84
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
66
85
  <span
67
86
  class="sv-rgp-chip"
68
87
  class:sv-rgp-chip-drag={dragIndex === i}
88
+ role="group"
89
+ tabindex="0"
90
+ aria-label={`Grouped by ${labelOf(id)}, position ${i + 1} of ${groupBy.length}. Press Alt+Arrow keys to reorder.`}
69
91
  draggable="true"
70
92
  ondragstart={() => (dragIndex = i)}
71
93
  ondragend={() => (dragIndex = null)}
72
94
  ondragover={(e) => e.preventDefault()}
73
95
  ondrop={() => reorder(i)}
96
+ onkeydown={(e) => onChipKeydown(e, i)}
74
97
  >
75
98
  <span class="sv-rgp-grip" aria-hidden="true">⠿</span>
76
99
  {labelOf(id)}
@@ -17,13 +17,24 @@
17
17
  height?: string
18
18
  /** Scroll horizontally instead of vertically. */
19
19
  horizontal?: boolean
20
+ /** Accessible name for the scrollable region, announced by screen readers. */
21
+ ariaLabel?: string
20
22
  children?: Snippet
21
23
  }
22
24
 
23
- let { maxHeight, height, horizontal = false, children }: Props = $props()
25
+ let { maxHeight, height, horizontal = false, ariaLabel = 'Scrollable content', children }: Props = $props()
24
26
  </script>
25
27
 
26
- <div class="sv-scroll" class:is-x={horizontal} style:max-height={maxHeight} style:height={height}>
28
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
29
+ <div
30
+ class="sv-scroll"
31
+ class:is-x={horizontal}
32
+ style:max-height={maxHeight}
33
+ style:height={height}
34
+ tabindex="0"
35
+ role="region"
36
+ aria-label={ariaLabel}
37
+ >
27
38
  {@render children?.()}
28
39
  </div>
29
40
 
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * SvTooltip - a small hover/focus tooltip anchored to its child, portalled to
4
4
  * <body> so it is never clipped. Shows after a short delay, hides on leave /
5
- * blur / Escape, and is wired via `aria-describedby` (role="tooltip"). Respects
6
- * keyboard users (opens on focus).
5
+ * blur / Escape, and is wired via `aria-describedby` (role="tooltip") onto the
6
+ * focusable child itself, so AT users get it announced on focus, not just
7
+ * sighted users on hover.
7
8
  *
8
9
  * ```svelte
9
10
  * <SvTooltip text="Delete row"><button aria-label="Delete">🗑</button></SvTooltip>
@@ -12,6 +13,7 @@
12
13
  import type { Snippet } from 'svelte'
13
14
  import { portalToBody, popIn } from './popover'
14
15
  import { nextEditorId } from './editor-contract'
16
+ import { getFocusable } from './a11y/focus-trap'
15
17
 
16
18
  type Props = {
17
19
  text: string
@@ -65,6 +67,29 @@
65
67
  document.removeEventListener('keydown', onKey)
66
68
  }
67
69
  })
70
+
71
+ // The wrapper span above is never itself Tab-focusable, so putting
72
+ // aria-describedby only on it does nothing for keyboard/AT users - they
73
+ // focus the real interactive child (e.g. the <button> in the doc example),
74
+ // and that's the element whose accessible description actually gets
75
+ // announced. Mirror the id onto that child too, merging with (and later
76
+ // restoring) any aria-describedby it already carries.
77
+ $effect(() => {
78
+ if (!open || !anchorEl) return
79
+ const child = getFocusable(anchorEl)[0]
80
+ if (!child) return
81
+ const before = child.getAttribute('aria-describedby')
82
+ const ids = new Set((before ?? '').split(/\s+/).filter(Boolean))
83
+ ids.add(tid)
84
+ child.setAttribute('aria-describedby', [...ids].join(' '))
85
+ return () => {
86
+ const remaining = (child.getAttribute('aria-describedby') ?? '')
87
+ .split(/\s+/)
88
+ .filter((id) => id && id !== tid)
89
+ if (remaining.length) child.setAttribute('aria-describedby', remaining.join(' '))
90
+ else child.removeAttribute('aria-describedby')
91
+ }
92
+ })
68
93
  </script>
69
94
 
70
95
  <!-- svelte-ignore a11y_no_static_element_interactions -->
package/src/SvTour.svelte CHANGED
@@ -18,6 +18,8 @@
18
18
  */
19
19
  import type { TourStep } from './ui-app.types'
20
20
  import { portalToBody } from './popover'
21
+ import { createFocusTrap } from './a11y/focus-trap'
22
+ import type { EditorDir } from './editor-contract'
21
23
 
22
24
  type Props = {
23
25
  steps: ReadonlyArray<TourStep>
@@ -25,12 +27,15 @@
25
27
  onFinish?: () => void
26
28
  onSkip?: () => void
27
29
  onStep?: (index: number) => void
30
+ /** Text direction; under `'rtl'` the Left/Right advance/back keys swap. */
31
+ dir?: EditorDir
28
32
  }
29
33
 
30
- let { steps, open = $bindable(false), onFinish, onSkip, onStep }: Props = $props()
34
+ let { steps, open = $bindable(false), onFinish, onSkip, onStep, dir }: Props = $props()
31
35
 
32
36
  let index = $state(0)
33
37
  let rect = $state<DOMRect | null>(null)
38
+ let popEl = $state<HTMLDivElement | null>(null)
34
39
  const step = $derived(steps[index])
35
40
 
36
41
  function targetEl(): HTMLElement | null {
@@ -43,10 +48,16 @@
43
48
  el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
44
49
  rect = el ? el.getBoundingClientRect() : null
45
50
  }
51
+ // Move focus into the popover on open + on every step change (its Next button
52
+ // is the sensible default target); the Tab trap (below) keeps it there.
53
+ function focusPopover() {
54
+ const btn = popEl?.querySelector<HTMLElement>('.sv-tour__next')
55
+ ;(btn ?? popEl)?.focus()
56
+ }
46
57
  function goto(i: number) {
47
58
  index = Math.max(0, Math.min(i, steps.length - 1))
48
59
  onStep?.(index)
49
- queueMicrotask(measure)
60
+ queueMicrotask(() => { measure(); focusPopover() })
50
61
  }
51
62
  function next() { if (index >= steps.length - 1) finish(); else goto(index + 1) }
52
63
  function back() { goto(index - 1) }
@@ -56,14 +67,15 @@
56
67
  $effect(() => {
57
68
  if (!open) return
58
69
  index = 0
59
- queueMicrotask(measure)
70
+ queueMicrotask(() => { measure(); focusPopover() })
60
71
  const reflow = () => measure()
61
72
  window.addEventListener('resize', reflow)
62
73
  window.addEventListener('scroll', reflow, true)
74
+ const rtl = dir === 'rtl'
63
75
  const onKey = (e: KeyboardEvent) => {
64
76
  if (e.key === 'Escape') { e.preventDefault(); skip() }
65
- else if (e.key === 'ArrowRight') { e.preventDefault(); next() }
66
- else if (e.key === 'ArrowLeft') { e.preventDefault(); back() }
77
+ else if (e.key === 'ArrowRight') { e.preventDefault(); (rtl ? back : next)() }
78
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); (rtl ? next : back)() }
67
79
  }
68
80
  window.addEventListener('keydown', onKey)
69
81
  return () => {
@@ -73,6 +85,18 @@
73
85
  }
74
86
  })
75
87
 
88
+ // Focus trap for the duration the tour is open, from the shared primitive
89
+ // (same one SvModal/SvDrawer use): traps Tab within the popover and restores
90
+ // focus to whatever triggered the tour once it closes.
91
+ $effect(() => {
92
+ if (!open || !popEl) return
93
+ const trap = createFocusTrap(popEl, {
94
+ initialFocus: () => popEl?.querySelector<HTMLElement>('.sv-tour__next') ?? null,
95
+ })
96
+ trap.activate()
97
+ return () => trap.release()
98
+ })
99
+
76
100
  const POP_W = 300
77
101
  const pop = $derived.by(() => {
78
102
  if (typeof window === 'undefined' || !rect) {
@@ -100,6 +124,7 @@
100
124
  {/if}
101
125
 
102
126
  <div
127
+ bind:this={popEl}
103
128
  class="sv-tour__pop sv-tour__pop--{pop.place}"
104
129
  style:top={pop.top}
105
130
  style:left={pop.left}
package/src/SvTree.svelte CHANGED
@@ -249,6 +249,7 @@
249
249
  {#snippet treeRow(item)}
250
250
  {@const cs = checkable ? tree.checkStateOf(item.node) : 'unchecked'}
251
251
  {@const isLoading = loadingIds.has(item.node.id)}
252
+ {@const rowProps = tree.itemProps(item)}
252
253
  <div
253
254
  class="sv-tree__row"
254
255
  class:is-selected={item.node.id === selected}
@@ -267,8 +268,11 @@
267
268
  ondrop={reorderable ? (e) => onRowDrop(e, item.node) : undefined}
268
269
  ondragend={reorderable ? endDrag : undefined}
269
270
  ondblclick={editable ? () => startEdit(item.node) : undefined}
270
- onkeydown={editable ? (e) => { if (e.key === 'F2') { e.preventDefault(); startEdit(item.node) } } : undefined}
271
- {...tree.itemProps(item)}
271
+ {...rowProps}
272
+ onkeydown={(e) => {
273
+ rowProps.onkeydown(e)
274
+ if (editable && e.key === 'F2') { e.preventDefault(); startEdit(item.node) }
275
+ }}
272
276
  >
273
277
  <button
274
278
  type="button"
@@ -277,7 +281,7 @@
277
281
  class:is-leaf={!item.hasChildren}
278
282
  class:is-loading={isLoading}
279
283
  tabindex="-1"
280
- aria-hidden={!item.hasChildren}
284
+ aria-hidden="true"
281
285
  onclick={(e) => { e.stopPropagation(); tree.toggleExpand(item.node) }}
282
286
  >
283
287
  {#if isLoading}
@@ -104,16 +104,23 @@
104
104
  estimatedHeight: () => 300,
105
105
  onExtraKey: (e, act) => {
106
106
  const row = rows[act]
107
- if (e.key === 'ArrowRight') {
108
- e.preventDefault()
109
- if (row?.hasChildren && !row.expanded) setExpanded(row.node.value, true)
110
- return true
107
+ if (!row) return false
108
+ // "expand" opens the node or steps into the first child; "collapse" closes
109
+ // it or jumps to the parent - same fallback as createTree's core.
110
+ const expand = () => {
111
+ if (row.hasChildren && !row.expanded) setExpanded(row.node.value, true)
112
+ else if (row.hasChildren && row.expanded) ts.active = Math.min(act + 1, rows.length - 1)
111
113
  }
112
- if (e.key === 'ArrowLeft') {
113
- e.preventDefault()
114
- if (row?.hasChildren && row.expanded) setExpanded(row.node.value, false)
115
- return true
114
+ const collapse = () => {
115
+ if (row.hasChildren && row.expanded) setExpanded(row.node.value, false)
116
+ else {
117
+ let pi = act - 1
118
+ while (pi >= 0 && rows[pi]!.depth >= row.depth) pi--
119
+ if (pi >= 0) ts.active = pi
120
+ }
116
121
  }
122
+ if (e.key === 'ArrowRight') { e.preventDefault(); expand(); return true }
123
+ if (e.key === 'ArrowLeft') { e.preventDefault(); collapse(); return true }
117
124
  return false
118
125
  },
119
126
  idPrefix: 'sv-ts',
@@ -129,7 +136,7 @@
129
136
  class:is-invalid={invalid}
130
137
  {disabled}
131
138
  onclick={() => ts.toggle()}
132
- {...ts.triggerProps()}
139
+ {...ts.triggerProps('tree')}
133
140
  {...editorAria({ id: uid, invalid, required, error, hint, ariaLabel })}
134
141
  >
135
142
  <span class="sv-ts__value" class:is-placeholder={!triggerText}>{triggerText || placeholder}</span>
package/src/build-api.ts CHANGED
@@ -239,6 +239,25 @@ export function createGridApi<
239
239
  [r.minRow, r.minCol, r.maxRow, r.maxCol] as [number, number, number, number],
240
240
  );
241
241
  },
242
+ openChart() {
243
+ ctx.chartPanelOpen = true;
244
+ },
245
+ closeChart() {
246
+ ctx.chartPanelOpen = false;
247
+ },
248
+ getChartSpec() {
249
+ return ctx.chartSpec;
250
+ },
251
+ chartRange(ranges) {
252
+ if (ranges && ranges.length) this.selectCells(ranges);
253
+ ctx.chartPanelOpen = true;
254
+ },
255
+ configureChart(config) {
256
+ if (ctx.chartingEnabled) ctx.applyChartConfig(config);
257
+ },
258
+ setChartAiHandler(handler) {
259
+ ctx.chartAiHandler = handler ?? null;
260
+ },
242
261
  addRow(row, position = "bottom") {
243
262
  this.addRows([row], position);
244
263
  },
@@ -752,6 +771,21 @@ export function createGridApi<
752
771
  Array.from(set),
753
772
  ]),
754
773
  ),
774
+ ...(ctx.chartingEnabled
775
+ ? {
776
+ chart: {
777
+ open: ctx.chartPanelOpen,
778
+ type: ctx.chartType,
779
+ dimension: ctx.chartDimensionId,
780
+ series: ctx.chartSeriesId,
781
+ measure: ctx.chartMeasureId,
782
+ reduce: ctx.chartReduce,
783
+ stacked: ctx.chartStacked,
784
+ },
785
+ charts: ctx.getChartsState(),
786
+ chartActive: ctx.activeChartIndex,
787
+ }
788
+ : {}),
755
789
  };
756
790
  },
757
791
  setState(state) {
@@ -785,6 +819,21 @@ export function createGridApi<
785
819
  next[k] = new Set(arr);
786
820
  ctx.valueFilters = next;
787
821
  }
822
+ if ((state.chart || state.charts) && ctx.chartingEnabled) {
823
+ if (Array.isArray(state.charts) && state.charts.length) {
824
+ ctx.applyChartsState(state.charts, state.chartActive);
825
+ }
826
+ if (state.chart) {
827
+ const c = state.chart;
828
+ ctx.chartPanelOpen = c.open;
829
+ ctx.chartType = c.type;
830
+ ctx.chartDimensionId = c.dimension;
831
+ ctx.chartSeriesId = c.series;
832
+ ctx.chartMeasureId = c.measure;
833
+ ctx.chartReduce = c.reduce;
834
+ ctx.chartStacked = c.stacked;
835
+ }
836
+ }
788
837
  },
789
838
  refresh() {
790
839
  ctx.grid.store.setState((prev: any) => ({ ...prev }));
@@ -9,6 +9,8 @@
9
9
  * Pass the chart's `<svg class="sv-grid-chart-svg">` element (or any ancestor
10
10
  * that contains it).
11
11
  */
12
+ import type { ChartSpec } from './chart'
13
+
12
14
  function resolveSvg(el: SVGSVGElement | HTMLElement): SVGSVGElement | null {
13
15
  if (el instanceof SVGSVGElement) return el
14
16
  return el.querySelector('svg.sv-grid-chart-svg') ?? el.querySelector('svg')
@@ -47,6 +49,29 @@ export function chartToSvgString(
47
49
  clone.setAttribute('width', String(w))
48
50
  clone.setAttribute('height', String(h))
49
51
  clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
52
+
53
+ // Inline computed paint/text styles so the export is what's on screen -
54
+ // dynamic SVG presentation attributes (a line's `stroke={color}`) and scoped
55
+ // component CSS don't survive a raw clone + XMLSerializer. Walk the live tree
56
+ // and its clone (1:1 here, before we prune hit layers) and copy the resolved
57
+ // style, so line paths etc. export with their real stroke instead of nothing.
58
+ const PAINT_PROPS = [
59
+ 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity',
60
+ 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'opacity',
61
+ 'font-size', 'font-family', 'font-weight', 'text-anchor',
62
+ ]
63
+ const liveNodes = svg.querySelectorAll('*')
64
+ const cloneNodes = clone.querySelectorAll('*')
65
+ for (let i = 0; i < liveNodes.length; i += 1) {
66
+ const el = cloneNodes[i] as (SVGElement & { style: CSSStyleDeclaration }) | undefined
67
+ if (!el || !el.style) continue
68
+ const cs = getComputedStyle(liveNodes[i]!)
69
+ for (const prop of PAINT_PROPS) {
70
+ const v = cs.getPropertyValue(prop)
71
+ if (v && v !== 'normal') el.style.setProperty(prop, v)
72
+ }
73
+ }
74
+
50
75
  // Drop interaction-only hit layers.
51
76
  clone.querySelectorAll('.sv-grid-chart-hit').forEach((n) => n.remove())
52
77
 
@@ -142,3 +167,35 @@ export async function downloadChartPng(
142
167
  triggerDownload(url, filename)
143
168
  setTimeout(() => URL.revokeObjectURL(url), 1000)
144
169
  }
170
+
171
+ const csvCell = (v: unknown): string => {
172
+ const s = v == null ? '' : String(v)
173
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
174
+ }
175
+
176
+ /**
177
+ * Serialize a chart's data to CSV: one row per category, one column per series
178
+ * (`Category, <series 1>, ...`). Empty for exotic specs (sankey/treemap/gauge/
179
+ * calendar) that don't carry a rectangular categories x series grid.
180
+ */
181
+ export function chartSpecToCsv(spec: ChartSpec): string {
182
+ const cats = spec.categories ?? []
183
+ const series = spec.series ?? []
184
+ if (!cats.length || !series.length) return ''
185
+ const header = ['Category', ...series.map((s) => s.label)].map(csvCell).join(',')
186
+ const rows = cats.map((cat, i) =>
187
+ [cat, ...series.map((s) => s.values[i] ?? '')].map(csvCell).join(','),
188
+ )
189
+ return [header, ...rows].join('\n')
190
+ }
191
+
192
+ /** Download the chart's data as a `.csv` file. Returns false if there's no
193
+ * rectangular category/series data to export. */
194
+ export function downloadChartCsv(spec: ChartSpec, filename = 'chart.csv'): boolean {
195
+ const csv = chartSpecToCsv(spec)
196
+ if (!csv) return false
197
+ const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
198
+ triggerDownload(url, filename)
199
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
200
+ return true
201
+ }