@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
@@ -18,6 +18,8 @@
18
18
  import type { Snippet } from 'svelte'
19
19
  import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
20
20
  import { createDismissableLayer } from './a11y/dismissable'
21
+ import { getFocusable } from './a11y/focus-trap'
22
+ import { type EditorDir } from './editor-contract'
21
23
  import SvMenuList, { type MenuItem } from './SvMenuList.svelte'
22
24
 
23
25
  let {
@@ -27,6 +29,7 @@
27
29
  onSelect,
28
30
  ariaLabel,
29
31
  anchor,
32
+ dir,
30
33
  }: {
31
34
  items: ReadonlyArray<MenuItem>
32
35
  open?: boolean
@@ -34,8 +37,12 @@
34
37
  onSelect?: (item: MenuItem) => void
35
38
  ariaLabel?: string
36
39
  anchor?: Snippet
40
+ /** Text direction (rtl mirrors the submenu chevron + flips ArrowLeft/Right). */
41
+ dir?: EditorDir
37
42
  } = $props()
38
43
 
44
+ const resolvedDir = $derived(dir === 'ltr' || dir === 'rtl' ? dir : undefined)
45
+
39
46
  let anchorEl = $state<HTMLSpanElement | null>(null)
40
47
  let panelEl = $state<HTMLDivElement | null>(null)
41
48
  let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
@@ -46,6 +53,13 @@
46
53
  if (!anchorEl) return
47
54
  rect = anchoredRect(anchorEl.getBoundingClientRect(), { estimatedHeight: Math.min(items.length, 10) * 34 + 8, minWidth: 180 })
48
55
  }
56
+ /** Return focus to the trigger. Every dismissal path (Escape, item selection,
57
+ * outside-click, Tab) funnels through the effect cleanup below, so this
58
+ * fires uniformly instead of dropping focus to <body> on close. */
59
+ function focusTrigger() {
60
+ if (!anchorEl) return
61
+ ;(getFocusable(anchorEl)[0] ?? anchorEl)?.focus?.()
62
+ }
49
63
 
50
64
  $effect(() => {
51
65
  if (!open) return
@@ -66,12 +80,20 @@
66
80
  window.removeEventListener('scroll', rp, true)
67
81
  window.removeEventListener('resize', rp)
68
82
  layer.release()
83
+ // Only on an actual close (not a mid-open rerun from e.g. `items` changing).
84
+ if (!open) focusTrigger()
69
85
  }
70
86
  })
71
87
  </script>
72
88
 
73
89
  <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
74
- <span bind:this={anchorEl} class="sv-menu__anchor" onclick={toggle}>
90
+ <span
91
+ bind:this={anchorEl}
92
+ class="sv-menu__anchor"
93
+ aria-haspopup="menu"
94
+ aria-expanded={open}
95
+ onclick={toggle}
96
+ >
75
97
  {@render anchor?.()}
76
98
  </span>
77
99
 
@@ -84,9 +106,10 @@
84
106
  style:position="fixed"
85
107
  style:top={`${rect.top}px`}
86
108
  style:left={`${rect.left}px`}
109
+ dir={resolvedDir}
87
110
  aria-label={ariaLabel}
88
111
  >
89
- <SvMenuList {items} onclose={() => setOpen(false)} onselect={(i) => onSelect?.(i)} />
112
+ <SvMenuList {items} onclose={() => setOpen(false)} onselect={(i) => onSelect?.(i)} dir={resolvedDir} />
90
113
  </div>
91
114
  {/if}
92
115
 
@@ -12,6 +12,7 @@ export type { MenuItem } from './SvMenuList.svelte';
12
12
  * ```
13
13
  */
14
14
  import type { Snippet } from 'svelte';
15
+ import { type EditorDir } from './editor-contract';
15
16
  import { type MenuItem } from './SvMenuList.svelte';
16
17
  type $$ComponentProps = {
17
18
  items: ReadonlyArray<MenuItem>;
@@ -20,6 +21,8 @@ type $$ComponentProps = {
20
21
  onSelect?: (item: MenuItem) => void;
21
22
  ariaLabel?: string;
22
23
  anchor?: Snippet;
24
+ /** Text direction (rtl mirrors the submenu chevron + flips ArrowLeft/Right). */
25
+ dir?: EditorDir;
23
26
  };
24
27
  declare const SvMenu: import("svelte").Component<$$ComponentProps, {}, "open">;
25
28
  type SvMenu = ReturnType<typeof SvMenu>;
@@ -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);
@@ -11,13 +11,21 @@ export type MenuItem = {
11
11
  children?: MenuItem[];
12
12
  onSelect?: () => void;
13
13
  };
14
+ import { type EditorDir } from './editor-contract';
14
15
  type $$ComponentProps = {
15
16
  items: ReadonlyArray<MenuItem>;
16
- /** Close the WHOLE menu (a leaf was chosen or Escape at the root). */
17
+ /** Close the WHOLE menu (a leaf was chosen, Escape, or outside-click). */
17
18
  onclose: () => void;
18
19
  /** Report the chosen leaf item. */
19
20
  onselect: (item: MenuItem) => void;
20
21
  submenu?: boolean;
22
+ /** Collapse just THIS submenu level (ArrowLeft, or ArrowRight under `rtl`)
23
+ * and refocus the item that opened it. Only meaningful when `submenu` is
24
+ * true; the root list has nowhere to collapse into. */
25
+ oncollapse?: () => void;
26
+ /** Text direction; under `rtl` the ArrowRight/ArrowLeft submenu open/close
27
+ * keys swap (see createTree.svelte.ts for the reference pattern). */
28
+ dir?: EditorDir;
21
29
  };
22
30
  declare const SvMenuList: import("svelte").Component<$$ComponentProps, {}, "">;
23
31
  type SvMenuList = ReturnType<typeof SvMenuList>;
@@ -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
 
@@ -15,6 +15,8 @@ type Props = {
15
15
  height?: string;
16
16
  /** Scroll horizontally instead of vertically. */
17
17
  horizontal?: boolean;
18
+ /** Accessible name for the scrollable region, announced by screen readers. */
19
+ ariaLabel?: string;
18
20
  children?: Snippet;
19
21
  };
20
22
  declare const SvScrollArea: import("svelte").Component<Props, {}, "">;
@@ -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 -->
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * SvTooltip - a small hover/focus tooltip anchored to its child, portalled to
3
3
  * <body> so it is never clipped. Shows after a short delay, hides on leave /
4
- * blur / Escape, and is wired via `aria-describedby` (role="tooltip"). Respects
5
- * keyboard users (opens on focus).
4
+ * blur / Escape, and is wired via `aria-describedby` (role="tooltip") onto the
5
+ * focusable child itself, so AT users get it announced on focus, not just
6
+ * sighted users on hover.
6
7
  *
7
8
  * ```svelte
8
9
  * <SvTooltip text="Delete row"><button aria-label="Delete">🗑</button></SvTooltip>
@@ -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}
@@ -13,12 +13,15 @@ export type { TourStep } from './ui-app.types';
13
13
  * ```
14
14
  */
15
15
  import type { TourStep } from './ui-app.types';
16
+ import type { EditorDir } from './editor-contract';
16
17
  type Props = {
17
18
  steps: ReadonlyArray<TourStep>;
18
19
  open?: boolean;
19
20
  onFinish?: () => void;
20
21
  onSkip?: () => void;
21
22
  onStep?: (index: number) => void;
23
+ /** Text direction; under `'rtl'` the Left/Right advance/back keys swap. */
24
+ dir?: EditorDir;
22
25
  };
23
26
  declare const SvTour: import("svelte").Component<Props, {}, "open">;
24
27
  type SvTour = ReturnType<typeof SvTour>;
@@ -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/dist/build-api.js CHANGED
@@ -94,6 +94,27 @@ export function createGridApi(ctx) {
94
94
  .getSelectionRects()
95
95
  .map((r) => [r.minRow, r.minCol, r.maxRow, r.maxCol]);
96
96
  },
97
+ openChart() {
98
+ ctx.chartPanelOpen = true;
99
+ },
100
+ closeChart() {
101
+ ctx.chartPanelOpen = false;
102
+ },
103
+ getChartSpec() {
104
+ return ctx.chartSpec;
105
+ },
106
+ chartRange(ranges) {
107
+ if (ranges && ranges.length)
108
+ this.selectCells(ranges);
109
+ ctx.chartPanelOpen = true;
110
+ },
111
+ configureChart(config) {
112
+ if (ctx.chartingEnabled)
113
+ ctx.applyChartConfig(config);
114
+ },
115
+ setChartAiHandler(handler) {
116
+ ctx.chartAiHandler = handler ?? null;
117
+ },
97
118
  addRow(row, position = "bottom") {
98
119
  this.addRows([row], position);
99
120
  },
@@ -584,6 +605,21 @@ export function createGridApi(ctx) {
584
605
  k,
585
606
  Array.from(set),
586
607
  ])),
608
+ ...(ctx.chartingEnabled
609
+ ? {
610
+ chart: {
611
+ open: ctx.chartPanelOpen,
612
+ type: ctx.chartType,
613
+ dimension: ctx.chartDimensionId,
614
+ series: ctx.chartSeriesId,
615
+ measure: ctx.chartMeasureId,
616
+ reduce: ctx.chartReduce,
617
+ stacked: ctx.chartStacked,
618
+ },
619
+ charts: ctx.getChartsState(),
620
+ chartActive: ctx.activeChartIndex,
621
+ }
622
+ : {}),
587
623
  };
588
624
  },
589
625
  setState(state) {
@@ -622,6 +658,21 @@ export function createGridApi(ctx) {
622
658
  next[k] = new Set(arr);
623
659
  ctx.valueFilters = next;
624
660
  }
661
+ if ((state.chart || state.charts) && ctx.chartingEnabled) {
662
+ if (Array.isArray(state.charts) && state.charts.length) {
663
+ ctx.applyChartsState(state.charts, state.chartActive);
664
+ }
665
+ if (state.chart) {
666
+ const c = state.chart;
667
+ ctx.chartPanelOpen = c.open;
668
+ ctx.chartType = c.type;
669
+ ctx.chartDimensionId = c.dimension;
670
+ ctx.chartSeriesId = c.series;
671
+ ctx.chartMeasureId = c.measure;
672
+ ctx.chartReduce = c.reduce;
673
+ ctx.chartStacked = c.stacked;
674
+ }
675
+ }
625
676
  },
626
677
  refresh() {
627
678
  ctx.grid.store.setState((prev) => ({ ...prev }));