@svgrid/grid 2.2.0 → 2.2.1

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 (114) hide show
  1. package/dist/FlexRender.svelte +96 -96
  2. package/dist/GridFooter.svelte +178 -178
  3. package/dist/SvCarousel.svelte +141 -141
  4. package/dist/SvColorInput.svelte +187 -187
  5. package/dist/SvContextMenu.svelte +116 -116
  6. package/dist/SvDrawer.svelte +250 -250
  7. package/dist/SvGrid.svelte +3131 -3131
  8. package/dist/SvGridBoard.svelte +2339 -2339
  9. package/dist/SvGridChart.svelte +1724 -1724
  10. package/dist/SvGridChartPanel.svelte +485 -485
  11. package/dist/SvGridDropdown.svelte +689 -689
  12. package/dist/SvGroupCell.svelte +109 -109
  13. package/dist/SvMenu.svelte +124 -124
  14. package/dist/SvMenuList.svelte +117 -117
  15. package/dist/SvNumberInput.svelte +131 -131
  16. package/dist/SvOtpInput.svelte +158 -158
  17. package/dist/SvPopover.svelte +139 -139
  18. package/dist/SvRowGroupPanel.svelte +170 -170
  19. package/dist/SvScrollArea.svelte +61 -61
  20. package/dist/SvTooltip.svelte +127 -127
  21. package/dist/SvTour.svelte +204 -204
  22. package/dist/SvTree.svelte +377 -377
  23. package/dist/SvTreeSelect.svelte +230 -230
  24. package/dist/cdn/svgrid.js +8 -8
  25. package/dist/cdn/svgrid.svelte-external.js +8 -8
  26. package/dist/chart-export.js +8 -8
  27. package/package.json +10 -10
  28. package/src/FlexRender.svelte +96 -96
  29. package/src/GridFooter.svelte +178 -178
  30. package/src/SvCarousel.svelte +141 -141
  31. package/src/SvColorInput.svelte +187 -187
  32. package/src/SvContextMenu.svelte +116 -116
  33. package/src/SvDrawer.svelte +250 -250
  34. package/src/SvGrid.controller.svelte.ts +3158 -3158
  35. package/src/SvGrid.svelte +3131 -3131
  36. package/src/SvGrid.types.ts +1489 -1489
  37. package/src/SvGridBoard.svelte +2339 -2339
  38. package/src/SvGridChart.svelte +1724 -1724
  39. package/src/SvGridChartPanel.svelte +485 -485
  40. package/src/SvGridDropdown.svelte +689 -689
  41. package/src/SvGroupCell.svelte +109 -109
  42. package/src/SvMenu.svelte +124 -124
  43. package/src/SvMenu.test.ts +97 -97
  44. package/src/SvMenuList.svelte +117 -117
  45. package/src/SvNumberInput.svelte +131 -131
  46. package/src/SvOtpInput.svelte +158 -158
  47. package/src/SvPopover.svelte +139 -139
  48. package/src/SvRowGroupPanel.svelte +170 -170
  49. package/src/SvScrollArea.svelte +61 -61
  50. package/src/SvTooltip.svelte +127 -127
  51. package/src/SvTour.svelte +204 -204
  52. package/src/SvTree.svelte +377 -377
  53. package/src/SvTreeSelect.svelte +230 -230
  54. package/src/a11y.contract.test.ts +49 -49
  55. package/src/a11y.test.ts +59 -59
  56. package/src/a11y.ts +61 -61
  57. package/src/build-api.ts +870 -870
  58. package/src/cell-formatting.ts +169 -169
  59. package/src/cell-render.ts +469 -469
  60. package/src/chart-export.ts +201 -201
  61. package/src/chart.ts +2296 -2296
  62. package/src/collaboration.test.ts +104 -104
  63. package/src/collaboration.ts +167 -167
  64. package/src/core.performance.test.ts +30 -30
  65. package/src/core.ts +1111 -1111
  66. package/src/createAutocomplete.svelte.ts +132 -132
  67. package/src/createCombobox.svelte.ts +179 -179
  68. package/src/createCountryInput.svelte.ts +157 -157
  69. package/src/createDropdownList.svelte.ts +162 -162
  70. package/src/createGrid.svelte.ts +42 -42
  71. package/src/createGrid.test.ts +10 -10
  72. package/src/createGridState.svelte.ts +17 -17
  73. package/src/createMenu.svelte.ts +202 -202
  74. package/src/createPopoverSelect.svelte.ts +206 -206
  75. package/src/createTree.svelte.ts +319 -319
  76. package/src/editing.test.ts +859 -859
  77. package/src/editing.ts +675 -675
  78. package/src/export-data-api.test.ts +126 -126
  79. package/src/export-format.test.ts +107 -107
  80. package/src/export-format.ts +598 -598
  81. package/src/flex-render.ts +3 -3
  82. package/src/index.ts +623 -623
  83. package/src/keyboard.test.ts +59 -59
  84. package/src/keyboard.ts +97 -97
  85. package/src/list-nav.test.ts +49 -49
  86. package/src/list-nav.ts +29 -29
  87. package/src/menus.ts +639 -639
  88. package/src/merge-objects.ts +48 -48
  89. package/src/overlays.test.ts +90 -90
  90. package/src/render-component.ts +28 -28
  91. package/src/selection.test.ts +754 -754
  92. package/src/selection.ts +600 -600
  93. package/src/server-data-source.test.ts +289 -289
  94. package/src/server-data-source.ts +413 -413
  95. package/src/sparkline.test.ts +68 -68
  96. package/src/sparkline.ts +169 -169
  97. package/src/spreadsheet.test.ts +489 -489
  98. package/src/spreadsheet.ts +312 -312
  99. package/src/static-functions.ts +11 -11
  100. package/src/subscribe.ts +38 -38
  101. package/src/svgrid-wrapper.types.ts +501 -501
  102. package/src/svgrid.behavior.test.ts +706 -706
  103. package/src/svgrid.charting.test.ts +527 -527
  104. package/src/svgrid.features.test.ts +157 -157
  105. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  106. package/src/svgrid.wrapper.test.ts +40 -40
  107. package/src/test-setup.ts +62 -62
  108. package/src/themes/index.ts +159 -159
  109. package/src/virtualization/column-virtualizer.test.ts +27 -27
  110. package/src/virtualization/column-virtualizer.ts +30 -30
  111. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  112. package/src/virtualization/types.ts +30 -30
  113. package/src/virtualization/virtualizer.test.ts +47 -47
  114. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,158 +1,158 @@
1
- <script lang="ts">
2
- /**
3
- * SvOtpInput - a segmented one-time-code / PIN entry: N single-char cells with
4
- * auto-advance, Backspace-to-previous, arrow navigation, and paste that
5
- * distributes across the cells. Emits the joined string. Parity: Smart OTP.
6
- *
7
- * ```svelte
8
- * <SvOtpInput length={6} value={code} onChange={(v) => (code = v)}
9
- * onComplete={(v) => verify(v)} />
10
- * ```
11
- */
12
- import SvField from './SvField.svelte'
13
- import { nextEditorId, type SvEditorProps } from './editor-contract'
14
- import { sanitizeOtp, otpCells, isOtpComplete } from './otp'
15
-
16
- type Props = Pick<
17
- SvEditorProps,
18
- 'disabled' | 'label' | 'hint' | 'error' | 'required' | 'dir' | 'size' | 'id' | 'name' | 'invalid'
19
- > & {
20
- value?: string
21
- onChange?: (value: string) => void
22
- /** Fired once every cell is filled. */
23
- onComplete?: (value: string) => void
24
- length?: number
25
- /** Digits only (default) vs any character. */
26
- numeric?: boolean
27
- /** Mask the characters (password dots). */
28
- mask?: boolean
29
- autofocus?: boolean
30
- }
31
-
32
- let {
33
- value = $bindable(''),
34
- onChange,
35
- onComplete,
36
- length = 6,
37
- numeric = true,
38
- mask = false,
39
- disabled = false,
40
- label,
41
- hint,
42
- error,
43
- required = false,
44
- dir,
45
- size = 'md',
46
- id,
47
- name,
48
- invalid = false,
49
- autofocus = false,
50
- }: Props = $props()
51
-
52
- const autoId = nextEditorId('sv-otp')
53
- const uid = $derived(id ?? autoId)
54
- const cells = $derived(otpCells(sanitizeOtp(value, length, numeric), length))
55
- let inputs = $state<HTMLInputElement[]>([])
56
-
57
- function commit(next: string) {
58
- const clean = sanitizeOtp(next, length, numeric)
59
- value = clean // enables bind:value
60
- onChange?.(clean)
61
- if (isOtpComplete(clean, length)) onComplete?.(clean)
62
- }
63
- function focusCell(i: number) {
64
- const el = inputs[Math.max(0, Math.min(length - 1, i))]
65
- el?.focus()
66
- el?.select()
67
- }
68
- function currentCells(): string[] {
69
- return otpCells(sanitizeOtp(value, length, numeric), length)
70
- }
71
-
72
- function onCellInput(i: number, e: Event) {
73
- const ch = sanitizeOtp((e.currentTarget as HTMLInputElement).value, 1, numeric).slice(-1)
74
- const arr = currentCells()
75
- arr[i] = ch
76
- commit(arr.join(''))
77
- if (ch) focusCell(i + 1)
78
- }
79
- function onCellKeydown(i: number, e: KeyboardEvent) {
80
- const rtl = dir === 'rtl'
81
- if (e.key === 'Backspace') {
82
- e.preventDefault()
83
- const arr = currentCells()
84
- if (arr[i]) {
85
- arr[i] = ''
86
- commit(arr.join(''))
87
- } else if (i > 0) {
88
- arr[i - 1] = ''
89
- commit(arr.join(''))
90
- focusCell(i - 1)
91
- }
92
- } else if (e.key === 'ArrowLeft') {
93
- e.preventDefault()
94
- focusCell(rtl ? i + 1 : i - 1)
95
- } else if (e.key === 'ArrowRight') {
96
- e.preventDefault()
97
- focusCell(rtl ? i - 1 : i + 1)
98
- }
99
- }
100
- function onPaste(i: number, e: ClipboardEvent) {
101
- e.preventDefault()
102
- const text = e.clipboardData?.getData('text') ?? ''
103
- const arr = currentCells()
104
- const pasted = sanitizeOtp(text, length - i, numeric)
105
- for (let k = 0; k < pasted.length; k++) arr[i + k] = pasted[k]!
106
- const joined = arr.join('')
107
- commit(joined)
108
- focusCell(Math.min(i + pasted.length, length - 1))
109
- }
110
- function mountFocus(node: HTMLInputElement, i: number) {
111
- if (autofocus && i === 0) node.focus()
112
- }
113
- </script>
114
-
115
- <SvField id={uid} {label} {hint} {error} {required} {dir}>
116
- <div
117
- class="sv-otp sv-otp--{size}"
118
- class:is-disabled={disabled}
119
- class:is-invalid={invalid}
120
- role="group"
121
- aria-label={label ?? 'One-time code'}
122
- >
123
- {#each cells as cell, i (i)}
124
- <input
125
- bind:this={inputs[i]}
126
- use:mountFocus={i}
127
- class="sv-otp__cell"
128
- type={mask ? 'password' : 'text'}
129
- inputmode={numeric ? 'numeric' : 'text'}
130
- autocomplete={i === 0 ? 'one-time-code' : 'off'}
131
- maxlength="1"
132
- value={cell}
133
- {disabled}
134
- aria-label={`Character ${i + 1} of ${length}`}
135
- oninput={(e) => onCellInput(i, e)}
136
- onkeydown={(e) => onCellKeydown(i, e)}
137
- onpaste={(e) => onPaste(i, e)}
138
- onfocus={(e) => (e.currentTarget as HTMLInputElement).select()}
139
- />
140
- {/each}
141
- {#if name}<input type="hidden" {name} value={value} />{/if}
142
- </div>
143
- </SvField>
144
-
145
- <style>
146
- .sv-otp { --_accent: var(--sg-accent, #2563eb); display: inline-flex; gap: 8px; }
147
- .sv-otp.is-disabled { opacity: 0.6; }
148
- .sv-otp__cell {
149
- width: 40px; height: 46px; text-align: center; font: inherit; font-weight: 650; font-size: 18px;
150
- background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
151
- border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
152
- outline: none; caret-color: var(--_accent);
153
- }
154
- .sv-otp--sm .sv-otp__cell { width: 34px; height: 40px; font-size: 16px; }
155
- .sv-otp--lg .sv-otp__cell { width: 46px; height: 54px; font-size: 20px; }
156
- .sv-otp__cell:focus { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
157
- .sv-otp.is-invalid .sv-otp__cell { border-color: var(--sg-danger, #dc2626); }
158
- </style>
1
+ <script lang="ts">
2
+ /**
3
+ * SvOtpInput - a segmented one-time-code / PIN entry: N single-char cells with
4
+ * auto-advance, Backspace-to-previous, arrow navigation, and paste that
5
+ * distributes across the cells. Emits the joined string. Parity: Smart OTP.
6
+ *
7
+ * ```svelte
8
+ * <SvOtpInput length={6} value={code} onChange={(v) => (code = v)}
9
+ * onComplete={(v) => verify(v)} />
10
+ * ```
11
+ */
12
+ import SvField from './SvField.svelte'
13
+ import { nextEditorId, type SvEditorProps } from './editor-contract'
14
+ import { sanitizeOtp, otpCells, isOtpComplete } from './otp'
15
+
16
+ type Props = Pick<
17
+ SvEditorProps,
18
+ 'disabled' | 'label' | 'hint' | 'error' | 'required' | 'dir' | 'size' | 'id' | 'name' | 'invalid'
19
+ > & {
20
+ value?: string
21
+ onChange?: (value: string) => void
22
+ /** Fired once every cell is filled. */
23
+ onComplete?: (value: string) => void
24
+ length?: number
25
+ /** Digits only (default) vs any character. */
26
+ numeric?: boolean
27
+ /** Mask the characters (password dots). */
28
+ mask?: boolean
29
+ autofocus?: boolean
30
+ }
31
+
32
+ let {
33
+ value = $bindable(''),
34
+ onChange,
35
+ onComplete,
36
+ length = 6,
37
+ numeric = true,
38
+ mask = false,
39
+ disabled = false,
40
+ label,
41
+ hint,
42
+ error,
43
+ required = false,
44
+ dir,
45
+ size = 'md',
46
+ id,
47
+ name,
48
+ invalid = false,
49
+ autofocus = false,
50
+ }: Props = $props()
51
+
52
+ const autoId = nextEditorId('sv-otp')
53
+ const uid = $derived(id ?? autoId)
54
+ const cells = $derived(otpCells(sanitizeOtp(value, length, numeric), length))
55
+ let inputs = $state<HTMLInputElement[]>([])
56
+
57
+ function commit(next: string) {
58
+ const clean = sanitizeOtp(next, length, numeric)
59
+ value = clean // enables bind:value
60
+ onChange?.(clean)
61
+ if (isOtpComplete(clean, length)) onComplete?.(clean)
62
+ }
63
+ function focusCell(i: number) {
64
+ const el = inputs[Math.max(0, Math.min(length - 1, i))]
65
+ el?.focus()
66
+ el?.select()
67
+ }
68
+ function currentCells(): string[] {
69
+ return otpCells(sanitizeOtp(value, length, numeric), length)
70
+ }
71
+
72
+ function onCellInput(i: number, e: Event) {
73
+ const ch = sanitizeOtp((e.currentTarget as HTMLInputElement).value, 1, numeric).slice(-1)
74
+ const arr = currentCells()
75
+ arr[i] = ch
76
+ commit(arr.join(''))
77
+ if (ch) focusCell(i + 1)
78
+ }
79
+ function onCellKeydown(i: number, e: KeyboardEvent) {
80
+ const rtl = dir === 'rtl'
81
+ if (e.key === 'Backspace') {
82
+ e.preventDefault()
83
+ const arr = currentCells()
84
+ if (arr[i]) {
85
+ arr[i] = ''
86
+ commit(arr.join(''))
87
+ } else if (i > 0) {
88
+ arr[i - 1] = ''
89
+ commit(arr.join(''))
90
+ focusCell(i - 1)
91
+ }
92
+ } else if (e.key === 'ArrowLeft') {
93
+ e.preventDefault()
94
+ focusCell(rtl ? i + 1 : i - 1)
95
+ } else if (e.key === 'ArrowRight') {
96
+ e.preventDefault()
97
+ focusCell(rtl ? i - 1 : i + 1)
98
+ }
99
+ }
100
+ function onPaste(i: number, e: ClipboardEvent) {
101
+ e.preventDefault()
102
+ const text = e.clipboardData?.getData('text') ?? ''
103
+ const arr = currentCells()
104
+ const pasted = sanitizeOtp(text, length - i, numeric)
105
+ for (let k = 0; k < pasted.length; k++) arr[i + k] = pasted[k]!
106
+ const joined = arr.join('')
107
+ commit(joined)
108
+ focusCell(Math.min(i + pasted.length, length - 1))
109
+ }
110
+ function mountFocus(node: HTMLInputElement, i: number) {
111
+ if (autofocus && i === 0) node.focus()
112
+ }
113
+ </script>
114
+
115
+ <SvField id={uid} {label} {hint} {error} {required} {dir}>
116
+ <div
117
+ class="sv-otp sv-otp--{size}"
118
+ class:is-disabled={disabled}
119
+ class:is-invalid={invalid}
120
+ role="group"
121
+ aria-label={label ?? 'One-time code'}
122
+ >
123
+ {#each cells as cell, i (i)}
124
+ <input
125
+ bind:this={inputs[i]}
126
+ use:mountFocus={i}
127
+ class="sv-otp__cell"
128
+ type={mask ? 'password' : 'text'}
129
+ inputmode={numeric ? 'numeric' : 'text'}
130
+ autocomplete={i === 0 ? 'one-time-code' : 'off'}
131
+ maxlength="1"
132
+ value={cell}
133
+ {disabled}
134
+ aria-label={`Character ${i + 1} of ${length}`}
135
+ oninput={(e) => onCellInput(i, e)}
136
+ onkeydown={(e) => onCellKeydown(i, e)}
137
+ onpaste={(e) => onPaste(i, e)}
138
+ onfocus={(e) => (e.currentTarget as HTMLInputElement).select()}
139
+ />
140
+ {/each}
141
+ {#if name}<input type="hidden" {name} value={value} />{/if}
142
+ </div>
143
+ </SvField>
144
+
145
+ <style>
146
+ .sv-otp { --_accent: var(--sg-accent, #2563eb); display: inline-flex; gap: 8px; }
147
+ .sv-otp.is-disabled { opacity: 0.6; }
148
+ .sv-otp__cell {
149
+ width: 40px; height: 46px; text-align: center; font: inherit; font-weight: 650; font-size: 18px;
150
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
151
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
152
+ outline: none; caret-color: var(--_accent);
153
+ }
154
+ .sv-otp--sm .sv-otp__cell { width: 34px; height: 40px; font-size: 16px; }
155
+ .sv-otp--lg .sv-otp__cell { width: 46px; height: 54px; font-size: 20px; }
156
+ .sv-otp__cell:focus { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
157
+ .sv-otp.is-invalid .sv-otp__cell { border-color: var(--sg-danger, #dc2626); }
158
+ </style>
@@ -1,139 +1,139 @@
1
- <script lang="ts">
2
- /**
3
- * SvPopover - a floating panel anchored to a trigger, portalled to <body> so it
4
- * escapes any `overflow:hidden` ancestor. Open on click (default), hover, or
5
- * manually via `open`. Flips above the trigger when there's no room below,
6
- * closes on outside-click / Escape, and animates in. Parity: Smart popover.
7
- *
8
- * ```svelte
9
- * <SvPopover>
10
- * {#snippet anchor()}<button>Open</button>{/snippet}
11
- * <p>Panel content</p>
12
- * </SvPopover>
13
- * ```
14
- */
15
- import type { Snippet } from 'svelte'
16
- import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
17
- import { createDismissableLayer } from './a11y/dismissable'
18
-
19
- type Props = {
20
- /** Controlled open state (bindable). */
21
- open?: boolean
22
- onOpenChange?: (open: boolean) => void
23
- /** How the anchor opens the panel. `manual` = only `open` drives it. */
24
- trigger?: 'click' | 'hover' | 'manual'
25
- /** Estimated panel height for flip decisions. */
26
- estimatedHeight?: number
27
- /** Force a minimum panel width (else it hugs its content). */
28
- minWidth?: number
29
- /** Close when clicking outside. Default true. */
30
- closeOnOutsideClick?: boolean
31
- ariaLabel?: string
32
- /** The anchor element(s) that open the panel. */
33
- anchor?: Snippet
34
- /** Panel content. */
35
- children?: Snippet
36
- }
37
-
38
- let {
39
- open = $bindable(false),
40
- onOpenChange,
41
- trigger = 'click',
42
- estimatedHeight = 220,
43
- minWidth,
44
- closeOnOutsideClick = true,
45
- ariaLabel,
46
- anchor,
47
- children,
48
- }: Props = $props()
49
-
50
- let anchorEl = $state<HTMLSpanElement | null>(null)
51
- let panelEl = $state<HTMLDivElement | null>(null)
52
- let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
53
-
54
- function setOpen(v: boolean) { if (v !== open) { open = v; onOpenChange?.(v) } }
55
- function toggle() { setOpen(!open) }
56
-
57
- function updatePos() {
58
- if (!anchorEl) return
59
- rect = anchoredRect(anchorEl.getBoundingClientRect(), { estimatedHeight, minWidth })
60
- }
61
-
62
- $effect(() => {
63
- if (!open) return
64
- updatePos()
65
- const rp = () => updatePos()
66
- window.addEventListener('scroll', rp, true)
67
- window.addEventListener('resize', rp)
68
- // Escape + outside-click dismissal via the shared layer stack (so a popover
69
- // nested in a dialog closes top-first). The trigger is passed alongside the
70
- // panel so clicking it toggles rather than dismiss-then-reopen.
71
- const layer = createDismissableLayer({
72
- element: () => [anchorEl, panelEl],
73
- onDismiss: () => setOpen(false),
74
- closeOnOutside: closeOnOutsideClick,
75
- })
76
- layer.activate()
77
- return () => {
78
- window.removeEventListener('scroll', rp, true)
79
- window.removeEventListener('resize', rp)
80
- layer.release()
81
- }
82
- })
83
-
84
- let hoverTimer: ReturnType<typeof setTimeout> | undefined
85
- function onEnter() { if (trigger === 'hover') { clearTimeout(hoverTimer); setOpen(true) } }
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
- }
95
- </script>
96
-
97
- <!-- svelte-ignore a11y_no_static_element_interactions -->
98
- <span
99
- bind:this={anchorEl}
100
- class="sv-pop__anchor"
101
- onclick={() => trigger === 'click' && toggle()}
102
- onpointerenter={onEnter}
103
- onpointerleave={onLeave}
104
- onfocusin={onEnter}
105
- onfocusout={onLeave}
106
- >
107
- {@render anchor?.()}
108
- </span>
109
-
110
- {#if open}
111
- <div
112
- bind:this={panelEl}
113
- class="sv-pop__panel"
114
- use:portalToBody
115
- use:popIn={{ up: rect.openUpward }}
116
- style:position="fixed"
117
- style:top={`${rect.top}px`}
118
- style:left={`${rect.left}px`}
119
- style:min-width={minWidth ? `${rect.width}px` : undefined}
120
- role="dialog"
121
- aria-label={ariaLabel ?? anchorFallbackLabel()}
122
- onpointerenter={onEnter}
123
- onpointerleave={onLeave}
124
- onfocusin={onEnter}
125
- onfocusout={onLeave}
126
- >
127
- {@render children?.()}
128
- </div>
129
- {/if}
130
-
131
- <style>
132
- .sv-pop__anchor { display: inline-flex; }
133
- :global(.sv-pop__panel) {
134
- z-index: 2147483646; max-width: min(92vw, 360px); padding: 12px 14px;
135
- background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
136
- border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
137
- box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35); font-size: 13px; line-height: 1.55;
138
- }
139
- </style>
1
+ <script lang="ts">
2
+ /**
3
+ * SvPopover - a floating panel anchored to a trigger, portalled to <body> so it
4
+ * escapes any `overflow:hidden` ancestor. Open on click (default), hover, or
5
+ * manually via `open`. Flips above the trigger when there's no room below,
6
+ * closes on outside-click / Escape, and animates in. Parity: Smart popover.
7
+ *
8
+ * ```svelte
9
+ * <SvPopover>
10
+ * {#snippet anchor()}<button>Open</button>{/snippet}
11
+ * <p>Panel content</p>
12
+ * </SvPopover>
13
+ * ```
14
+ */
15
+ import type { Snippet } from 'svelte'
16
+ import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
17
+ import { createDismissableLayer } from './a11y/dismissable'
18
+
19
+ type Props = {
20
+ /** Controlled open state (bindable). */
21
+ open?: boolean
22
+ onOpenChange?: (open: boolean) => void
23
+ /** How the anchor opens the panel. `manual` = only `open` drives it. */
24
+ trigger?: 'click' | 'hover' | 'manual'
25
+ /** Estimated panel height for flip decisions. */
26
+ estimatedHeight?: number
27
+ /** Force a minimum panel width (else it hugs its content). */
28
+ minWidth?: number
29
+ /** Close when clicking outside. Default true. */
30
+ closeOnOutsideClick?: boolean
31
+ ariaLabel?: string
32
+ /** The anchor element(s) that open the panel. */
33
+ anchor?: Snippet
34
+ /** Panel content. */
35
+ children?: Snippet
36
+ }
37
+
38
+ let {
39
+ open = $bindable(false),
40
+ onOpenChange,
41
+ trigger = 'click',
42
+ estimatedHeight = 220,
43
+ minWidth,
44
+ closeOnOutsideClick = true,
45
+ ariaLabel,
46
+ anchor,
47
+ children,
48
+ }: Props = $props()
49
+
50
+ let anchorEl = $state<HTMLSpanElement | null>(null)
51
+ let panelEl = $state<HTMLDivElement | null>(null)
52
+ let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
53
+
54
+ function setOpen(v: boolean) { if (v !== open) { open = v; onOpenChange?.(v) } }
55
+ function toggle() { setOpen(!open) }
56
+
57
+ function updatePos() {
58
+ if (!anchorEl) return
59
+ rect = anchoredRect(anchorEl.getBoundingClientRect(), { estimatedHeight, minWidth })
60
+ }
61
+
62
+ $effect(() => {
63
+ if (!open) return
64
+ updatePos()
65
+ const rp = () => updatePos()
66
+ window.addEventListener('scroll', rp, true)
67
+ window.addEventListener('resize', rp)
68
+ // Escape + outside-click dismissal via the shared layer stack (so a popover
69
+ // nested in a dialog closes top-first). The trigger is passed alongside the
70
+ // panel so clicking it toggles rather than dismiss-then-reopen.
71
+ const layer = createDismissableLayer({
72
+ element: () => [anchorEl, panelEl],
73
+ onDismiss: () => setOpen(false),
74
+ closeOnOutside: closeOnOutsideClick,
75
+ })
76
+ layer.activate()
77
+ return () => {
78
+ window.removeEventListener('scroll', rp, true)
79
+ window.removeEventListener('resize', rp)
80
+ layer.release()
81
+ }
82
+ })
83
+
84
+ let hoverTimer: ReturnType<typeof setTimeout> | undefined
85
+ function onEnter() { if (trigger === 'hover') { clearTimeout(hoverTimer); setOpen(true) } }
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
+ }
95
+ </script>
96
+
97
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
98
+ <span
99
+ bind:this={anchorEl}
100
+ class="sv-pop__anchor"
101
+ onclick={() => trigger === 'click' && toggle()}
102
+ onpointerenter={onEnter}
103
+ onpointerleave={onLeave}
104
+ onfocusin={onEnter}
105
+ onfocusout={onLeave}
106
+ >
107
+ {@render anchor?.()}
108
+ </span>
109
+
110
+ {#if open}
111
+ <div
112
+ bind:this={panelEl}
113
+ class="sv-pop__panel"
114
+ use:portalToBody
115
+ use:popIn={{ up: rect.openUpward }}
116
+ style:position="fixed"
117
+ style:top={`${rect.top}px`}
118
+ style:left={`${rect.left}px`}
119
+ style:min-width={minWidth ? `${rect.width}px` : undefined}
120
+ role="dialog"
121
+ aria-label={ariaLabel ?? anchorFallbackLabel()}
122
+ onpointerenter={onEnter}
123
+ onpointerleave={onLeave}
124
+ onfocusin={onEnter}
125
+ onfocusout={onLeave}
126
+ >
127
+ {@render children?.()}
128
+ </div>
129
+ {/if}
130
+
131
+ <style>
132
+ .sv-pop__anchor { display: inline-flex; }
133
+ :global(.sv-pop__panel) {
134
+ z-index: 2147483646; max-width: min(92vw, 360px); padding: 12px 14px;
135
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
136
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
137
+ box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35); font-size: 13px; line-height: 1.55;
138
+ }
139
+ </style>