@dodlhuat/basix 1.2.0 → 1.2.2

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 (93) hide show
  1. package/README.md +266 -6
  2. package/css/accordion.scss +86 -87
  3. package/css/alert.scss +137 -137
  4. package/css/button.scss +48 -0
  5. package/css/calendar.scss +957 -0
  6. package/css/card.scss +65 -65
  7. package/css/chart.scss +270 -157
  8. package/css/chat-bubbles.scss +134 -68
  9. package/css/chips.scss +109 -19
  10. package/css/colors.scss +32 -32
  11. package/css/datepicker.scss +336 -336
  12. package/css/defaults.scss +90 -90
  13. package/css/docs.scss +529 -0
  14. package/css/editor.scss +36 -0
  15. package/css/file-uploader.scss +1 -1
  16. package/css/flyout-menu.scss +361 -361
  17. package/css/form.scss +0 -15
  18. package/css/gallery.scss +65 -6
  19. package/css/grid.scss +41 -40
  20. package/css/group-picker.scss +345 -0
  21. package/css/guitar-chords.css +250 -250
  22. package/css/icons.scss +330 -330
  23. package/css/parameters.scss +3 -3
  24. package/css/placeholder.scss +33 -33
  25. package/css/popover.scss +206 -0
  26. package/css/progress.scss +76 -32
  27. package/css/properties.scss +51 -36
  28. package/css/push-menu.scss +302 -174
  29. package/css/reset.scss +39 -39
  30. package/css/scrollbar.scss +62 -5
  31. package/css/sidebar-nav.scss +92 -0
  32. package/css/spinner.scss +65 -65
  33. package/css/stepper.scss +48 -12
  34. package/css/style.css +3155 -254
  35. package/css/style.css.map +1 -1
  36. package/css/style.min.css +1 -1
  37. package/css/style.scss +51 -45
  38. package/css/table.scss +199 -199
  39. package/css/tabs.scss +154 -123
  40. package/css/timeline.scss +83 -38
  41. package/css/timepicker.scss +100 -5
  42. package/css/toast.scss +81 -81
  43. package/css/virtual-dropdown.scss +35 -29
  44. package/js/calendar.js +532 -0
  45. package/js/calendar.ts +706 -0
  46. package/js/chart.js +573 -257
  47. package/js/chart.ts +692 -0
  48. package/js/code-viewer.js +10 -10
  49. package/js/code-viewer.ts +188 -188
  50. package/js/datepicker.ts +627 -627
  51. package/js/docs-nav.js +204 -0
  52. package/js/dropdown.ts +179 -179
  53. package/js/editor.js +50 -6
  54. package/js/editor.ts +483 -444
  55. package/js/file-uploader.js +1 -0
  56. package/js/file-uploader.ts +1 -0
  57. package/js/flyout-menu.js +14 -14
  58. package/js/flyout-menu.ts +249 -249
  59. package/js/form-builder.js +106 -106
  60. package/js/gallery.js +14 -8
  61. package/js/gallery.ts +245 -236
  62. package/js/group-picker.js +342 -0
  63. package/js/group-picker.ts +447 -0
  64. package/js/guitar-chords.js +268 -268
  65. package/js/lazy-loader.js +121 -121
  66. package/js/modal.ts +166 -166
  67. package/js/popover.js +163 -0
  68. package/js/popover.ts +219 -0
  69. package/js/position.js +108 -0
  70. package/js/position.ts +111 -0
  71. package/js/push-menu.js +113 -0
  72. package/js/push-menu.ts +284 -145
  73. package/js/request.js +50 -50
  74. package/js/scroll.ts +47 -47
  75. package/js/scrollbar.js +13 -0
  76. package/js/scrollbar.ts +324 -307
  77. package/js/select.ts +216 -216
  78. package/js/sidebar-nav.js +41 -0
  79. package/js/sidebar-nav.ts +66 -0
  80. package/js/table.ts +452 -452
  81. package/js/tabs.ts +279 -279
  82. package/js/theme.js +17 -6
  83. package/js/theme.ts +234 -224
  84. package/js/toast.ts +137 -137
  85. package/js/tooltip.js +6 -60
  86. package/js/tooltip.ts +184 -251
  87. package/js/tsconfig.json +18 -18
  88. package/js/utils.ts +83 -83
  89. package/js/virtual-dropdown.js +25 -25
  90. package/js/virtual-dropdown.ts +365 -365
  91. package/package.json +37 -39
  92. package/js/index.js +0 -816
  93. package/js/index.ts +0 -987
package/js/popover.js ADDED
@@ -0,0 +1,163 @@
1
+ import { computePosition } from './position.js';
2
+ // Must match $arrow in popover.scss
3
+ const ARROW_SIZE = 6;
4
+ class Popover {
5
+ constructor(triggerEl, options) {
6
+ this.popoverEl = null;
7
+ this._isOpen = false;
8
+ this.hoverTimer = null;
9
+ // ── Event handlers ─────────────────────────────────────────────────────────
10
+ this.onClick = () => { this.toggle(); };
11
+ this.onMouseEnter = () => {
12
+ if (this.hoverTimer !== null)
13
+ clearTimeout(this.hoverTimer);
14
+ this.open();
15
+ };
16
+ this.onMouseLeave = () => {
17
+ this.hoverTimer = window.setTimeout(() => this.close(), 120);
18
+ };
19
+ this.onOutsideClick = (e) => {
20
+ const t = e.target;
21
+ if (!this.popoverEl?.contains(t) && !this.trigger.contains(t))
22
+ this.close();
23
+ };
24
+ this.onEscape = (e) => {
25
+ if (e.key === 'Escape')
26
+ this.close();
27
+ };
28
+ const el = typeof triggerEl === 'string'
29
+ ? document.querySelector(triggerEl)
30
+ : triggerEl;
31
+ if (!el)
32
+ throw new Error('Popover: trigger element not found');
33
+ this.trigger = el;
34
+ this.opts = {
35
+ content: options.content,
36
+ placement: options.placement ?? 'bottom',
37
+ align: options.align ?? 'center',
38
+ offset: options.offset ?? 8,
39
+ arrow: options.arrow ?? true,
40
+ triggerMode: options.triggerMode ?? 'click',
41
+ closeOnOutsideClick: options.closeOnOutsideClick ?? true,
42
+ closeOnEscape: options.closeOnEscape ?? true,
43
+ className: options.className ?? '',
44
+ onOpen: options.onOpen ?? (() => { }),
45
+ onClose: options.onClose ?? (() => { }),
46
+ };
47
+ this.attachTrigger();
48
+ }
49
+ // ── Public API ─────────────────────────────────────────────────────────────
50
+ get isOpen() { return this._isOpen; }
51
+ open() {
52
+ if (this._isOpen)
53
+ return;
54
+ if (this.opts.triggerMode === 'click')
55
+ Popover.closeAll();
56
+ this.popoverEl = this.buildEl();
57
+ document.body.appendChild(this.popoverEl);
58
+ this.reposition();
59
+ requestAnimationFrame(() => {
60
+ this.popoverEl?.classList.add('is-open');
61
+ this._isOpen = true;
62
+ Popover.openPopovers.add(this);
63
+ this.opts.onOpen();
64
+ if (this.opts.closeOnOutsideClick)
65
+ document.addEventListener('pointerdown', this.onOutsideClick, { capture: true });
66
+ if (this.opts.closeOnEscape)
67
+ document.addEventListener('keydown', this.onEscape);
68
+ });
69
+ }
70
+ close() {
71
+ if (!this._isOpen || !this.popoverEl)
72
+ return;
73
+ this.popoverEl.classList.remove('is-open');
74
+ this._isOpen = false;
75
+ Popover.openPopovers.delete(this);
76
+ this.opts.onClose();
77
+ document.removeEventListener('pointerdown', this.onOutsideClick, { capture: true });
78
+ document.removeEventListener('keydown', this.onEscape);
79
+ this.trigger.removeAttribute('aria-expanded');
80
+ this.trigger.removeAttribute('aria-controls');
81
+ const el = this.popoverEl;
82
+ setTimeout(() => el.remove(), 200);
83
+ this.popoverEl = null;
84
+ }
85
+ toggle() { this._isOpen ? this.close() : this.open(); }
86
+ destroy() {
87
+ this.close();
88
+ this.detachTrigger();
89
+ }
90
+ static closeAll() {
91
+ Popover.openPopovers.forEach(p => p.close());
92
+ }
93
+ /** Declarative init — reads [data-popover="#selector"] attributes */
94
+ static initAll() {
95
+ document.querySelectorAll('[data-popover]').forEach(trigger => {
96
+ const sel = trigger.getAttribute('data-popover');
97
+ if (!sel)
98
+ return;
99
+ const contentEl = document.querySelector(sel);
100
+ if (!contentEl)
101
+ return;
102
+ new Popover(trigger, {
103
+ content: contentEl.innerHTML,
104
+ placement: trigger.getAttribute('data-popover-placement') ?? 'bottom',
105
+ align: trigger.getAttribute('data-popover-align') ?? 'center',
106
+ triggerMode: trigger.getAttribute('data-popover-trigger') ?? 'click',
107
+ arrow: trigger.getAttribute('data-popover-arrow') !== 'false',
108
+ });
109
+ });
110
+ }
111
+ // ── Build ──────────────────────────────────────────────────────────────────
112
+ buildEl() {
113
+ const id = `popover-${++Popover.idCounter}`;
114
+ const el = document.createElement('div');
115
+ el.className = ['popover', this.opts.className].filter(Boolean).join(' ');
116
+ el.id = id;
117
+ el.setAttribute('role', 'dialog');
118
+ el.setAttribute('data-arrow', String(this.opts.arrow));
119
+ // Wrap plain content in .popover-body so it gets proper padding.
120
+ // Skip wrapping when content already uses structured popover elements.
121
+ const hasStructure = /class="popover-(header|body|footer|menu)/.test(this.opts.content);
122
+ el.innerHTML = hasStructure
123
+ ? this.opts.content
124
+ : `<div class="popover-body">${this.opts.content}</div>`;
125
+ this.trigger.setAttribute('aria-expanded', 'true');
126
+ this.trigger.setAttribute('aria-controls', id);
127
+ return el;
128
+ }
129
+ // ── Positioning ────────────────────────────────────────────────────────────
130
+ reposition() {
131
+ if (!this.popoverEl)
132
+ return;
133
+ const { left, top, placement, arrowOffset } = computePosition(this.trigger.getBoundingClientRect(), this.popoverEl.getBoundingClientRect(), {
134
+ placement: this.opts.placement,
135
+ align: this.opts.align,
136
+ offset: this.opts.offset,
137
+ arrowSize: this.opts.arrow ? ARROW_SIZE : undefined,
138
+ });
139
+ if (arrowOffset !== undefined)
140
+ this.popoverEl.style.setProperty('--popover-arrow-offset', `${arrowOffset}px`);
141
+ this.popoverEl.setAttribute('data-placement', placement);
142
+ this.popoverEl.setAttribute('data-align', this.opts.align);
143
+ this.popoverEl.style.left = `${left}px`;
144
+ this.popoverEl.style.top = `${top}px`;
145
+ }
146
+ attachTrigger() {
147
+ if (this.opts.triggerMode === 'click') {
148
+ this.trigger.addEventListener('click', this.onClick);
149
+ }
150
+ else {
151
+ this.trigger.addEventListener('mouseenter', this.onMouseEnter);
152
+ this.trigger.addEventListener('mouseleave', this.onMouseLeave);
153
+ }
154
+ }
155
+ detachTrigger() {
156
+ this.trigger.removeEventListener('click', this.onClick);
157
+ this.trigger.removeEventListener('mouseenter', this.onMouseEnter);
158
+ this.trigger.removeEventListener('mouseleave', this.onMouseLeave);
159
+ }
160
+ }
161
+ Popover.openPopovers = new Set();
162
+ Popover.idCounter = 0;
163
+ export { Popover };
package/js/popover.ts ADDED
@@ -0,0 +1,219 @@
1
+ import { computePosition } from './position.js';
2
+ import type { Placement } from './position.js';
3
+
4
+ type PopoverPlacement = Placement | 'auto';
5
+ type PopoverAlign = 'start' | 'center' | 'end';
6
+ type PopoverTrigger = 'click' | 'hover';
7
+
8
+ interface PopoverOptions {
9
+ content: string;
10
+ placement?: PopoverPlacement;
11
+ align?: PopoverAlign;
12
+ offset?: number;
13
+ arrow?: boolean;
14
+ triggerMode?: PopoverTrigger;
15
+ closeOnOutsideClick?: boolean;
16
+ closeOnEscape?: boolean;
17
+ className?: string;
18
+ onOpen?: () => void;
19
+ onClose?: () => void;
20
+ }
21
+
22
+ // Must match $arrow in popover.scss
23
+ const ARROW_SIZE = 6;
24
+
25
+ class Popover {
26
+ private static openPopovers: Set<Popover> = new Set();
27
+ private static idCounter = 0;
28
+
29
+ private readonly trigger: HTMLElement;
30
+ private readonly opts: Required<PopoverOptions>;
31
+ private popoverEl: HTMLElement | null = null;
32
+ private _isOpen = false;
33
+ private hoverTimer: number | null = null;
34
+
35
+ constructor(triggerEl: HTMLElement | string, options: PopoverOptions) {
36
+ const el = typeof triggerEl === 'string'
37
+ ? document.querySelector<HTMLElement>(triggerEl)
38
+ : triggerEl;
39
+ if (!el) throw new Error('Popover: trigger element not found');
40
+
41
+ this.trigger = el;
42
+ this.opts = {
43
+ content: options.content,
44
+ placement: options.placement ?? 'bottom',
45
+ align: options.align ?? 'center',
46
+ offset: options.offset ?? 8,
47
+ arrow: options.arrow ?? true,
48
+ triggerMode: options.triggerMode ?? 'click',
49
+ closeOnOutsideClick: options.closeOnOutsideClick ?? true,
50
+ closeOnEscape: options.closeOnEscape ?? true,
51
+ className: options.className ?? '',
52
+ onOpen: options.onOpen ?? (() => {}),
53
+ onClose: options.onClose ?? (() => {}),
54
+ };
55
+
56
+ this.attachTrigger();
57
+ }
58
+
59
+ // ── Public API ─────────────────────────────────────────────────────────────
60
+
61
+ get isOpen(): boolean { return this._isOpen; }
62
+
63
+ open(): void {
64
+ if (this._isOpen) return;
65
+ if (this.opts.triggerMode === 'click') Popover.closeAll();
66
+
67
+ this.popoverEl = this.buildEl();
68
+ document.body.appendChild(this.popoverEl);
69
+ this.reposition();
70
+
71
+ requestAnimationFrame(() => {
72
+ this.popoverEl?.classList.add('is-open');
73
+ this._isOpen = true;
74
+ Popover.openPopovers.add(this);
75
+ this.opts.onOpen();
76
+
77
+ if (this.opts.closeOnOutsideClick)
78
+ document.addEventListener('pointerdown', this.onOutsideClick, { capture: true });
79
+ if (this.opts.closeOnEscape)
80
+ document.addEventListener('keydown', this.onEscape);
81
+ });
82
+ }
83
+
84
+ close(): void {
85
+ if (!this._isOpen || !this.popoverEl) return;
86
+
87
+ this.popoverEl.classList.remove('is-open');
88
+ this._isOpen = false;
89
+ Popover.openPopovers.delete(this);
90
+ this.opts.onClose();
91
+
92
+ document.removeEventListener('pointerdown', this.onOutsideClick, { capture: true });
93
+ document.removeEventListener('keydown', this.onEscape);
94
+
95
+ this.trigger.removeAttribute('aria-expanded');
96
+ this.trigger.removeAttribute('aria-controls');
97
+
98
+ const el = this.popoverEl;
99
+ setTimeout(() => el.remove(), 200);
100
+ this.popoverEl = null;
101
+ }
102
+
103
+ toggle(): void { this._isOpen ? this.close() : this.open(); }
104
+
105
+ destroy(): void {
106
+ this.close();
107
+ this.detachTrigger();
108
+ }
109
+
110
+ static closeAll(): void {
111
+ Popover.openPopovers.forEach(p => p.close());
112
+ }
113
+
114
+ /** Declarative init — reads [data-popover="#selector"] attributes */
115
+ static initAll(): void {
116
+ document.querySelectorAll<HTMLElement>('[data-popover]').forEach(trigger => {
117
+ const sel = trigger.getAttribute('data-popover');
118
+ if (!sel) return;
119
+ const contentEl = document.querySelector(sel);
120
+ if (!contentEl) return;
121
+
122
+ new Popover(trigger, {
123
+ content: (contentEl as HTMLElement).innerHTML,
124
+ placement: (trigger.getAttribute('data-popover-placement') as PopoverPlacement) ?? 'bottom',
125
+ align: (trigger.getAttribute('data-popover-align') as PopoverAlign) ?? 'center',
126
+ triggerMode:(trigger.getAttribute('data-popover-trigger') as PopoverTrigger) ?? 'click',
127
+ arrow: trigger.getAttribute('data-popover-arrow') !== 'false',
128
+ });
129
+ });
130
+ }
131
+
132
+ // ── Build ──────────────────────────────────────────────────────────────────
133
+
134
+ private buildEl(): HTMLElement {
135
+ const id = `popover-${++Popover.idCounter}`;
136
+ const el = document.createElement('div');
137
+ el.className = ['popover', this.opts.className].filter(Boolean).join(' ');
138
+ el.id = id;
139
+ el.setAttribute('role', 'dialog');
140
+ el.setAttribute('data-arrow', String(this.opts.arrow));
141
+
142
+ // Wrap plain content in .popover-body so it gets proper padding.
143
+ // Skip wrapping when content already uses structured popover elements.
144
+ const hasStructure = /class="popover-(header|body|footer|menu)/.test(this.opts.content);
145
+ el.innerHTML = hasStructure
146
+ ? this.opts.content
147
+ : `<div class="popover-body">${this.opts.content}</div>`;
148
+
149
+ this.trigger.setAttribute('aria-expanded', 'true');
150
+ this.trigger.setAttribute('aria-controls', id);
151
+
152
+ return el;
153
+ }
154
+
155
+ // ── Positioning ────────────────────────────────────────────────────────────
156
+
157
+ private reposition(): void {
158
+ if (!this.popoverEl) return;
159
+
160
+ const { left, top, placement, arrowOffset } = computePosition(
161
+ this.trigger.getBoundingClientRect(),
162
+ this.popoverEl.getBoundingClientRect(),
163
+ {
164
+ placement: this.opts.placement,
165
+ align: this.opts.align,
166
+ offset: this.opts.offset,
167
+ arrowSize: this.opts.arrow ? ARROW_SIZE : undefined,
168
+ }
169
+ );
170
+
171
+ if (arrowOffset !== undefined)
172
+ this.popoverEl.style.setProperty('--popover-arrow-offset', `${arrowOffset}px`);
173
+
174
+ this.popoverEl.setAttribute('data-placement', placement);
175
+ this.popoverEl.setAttribute('data-align', this.opts.align);
176
+ this.popoverEl.style.left = `${left}px`;
177
+ this.popoverEl.style.top = `${top}px`;
178
+ }
179
+
180
+ // ── Event handlers ─────────────────────────────────────────────────────────
181
+
182
+ private onClick = (): void => { this.toggle(); };
183
+
184
+ private onMouseEnter = (): void => {
185
+ if (this.hoverTimer !== null) clearTimeout(this.hoverTimer);
186
+ this.open();
187
+ };
188
+
189
+ private onMouseLeave = (): void => {
190
+ this.hoverTimer = window.setTimeout(() => this.close(), 120);
191
+ };
192
+
193
+ private onOutsideClick = (e: Event): void => {
194
+ const t = e.target as Node;
195
+ if (!this.popoverEl?.contains(t) && !this.trigger.contains(t)) this.close();
196
+ };
197
+
198
+ private onEscape = (e: KeyboardEvent): void => {
199
+ if (e.key === 'Escape') this.close();
200
+ };
201
+
202
+ private attachTrigger(): void {
203
+ if (this.opts.triggerMode === 'click') {
204
+ this.trigger.addEventListener('click', this.onClick);
205
+ } else {
206
+ this.trigger.addEventListener('mouseenter', this.onMouseEnter);
207
+ this.trigger.addEventListener('mouseleave', this.onMouseLeave);
208
+ }
209
+ }
210
+
211
+ private detachTrigger(): void {
212
+ this.trigger.removeEventListener('click', this.onClick);
213
+ this.trigger.removeEventListener('mouseenter', this.onMouseEnter);
214
+ this.trigger.removeEventListener('mouseleave', this.onMouseLeave);
215
+ }
216
+ }
217
+
218
+ export { Popover };
219
+ export type { PopoverOptions, PopoverPlacement, PopoverAlign, PopoverTrigger };
package/js/position.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared floating-element positioning utility.
3
+ * Used by Tooltip and Popover.
4
+ */
5
+ /** Pick the placement with the most available space, preferring bottom > top > right > left. */
6
+ function bestPlacement(trigger, floating, offset) {
7
+ const space = {
8
+ bottom: window.innerHeight - trigger.bottom - offset,
9
+ top: trigger.top - offset,
10
+ right: window.innerWidth - trigger.right - offset,
11
+ left: trigger.left - offset,
12
+ };
13
+ if (space.bottom >= floating.height)
14
+ return 'bottom';
15
+ if (space.top >= floating.height)
16
+ return 'top';
17
+ if (space.right >= floating.width)
18
+ return 'right';
19
+ if (space.left >= floating.width)
20
+ return 'left';
21
+ // Fallback: largest available side
22
+ return (Object.entries(space).sort((a, b) => b[1] - a[1])[0][0]);
23
+ }
24
+ /** Flip to opposite side if preferred placement doesn't fit. */
25
+ function maybeFlip(placement, trigger, floating, offset) {
26
+ const fits = {
27
+ top: trigger.top - offset >= floating.height,
28
+ bottom: window.innerHeight - trigger.bottom - offset >= floating.height,
29
+ left: trigger.left - offset >= floating.width,
30
+ right: window.innerWidth - trigger.right - offset >= floating.width,
31
+ };
32
+ const opp = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
33
+ return (!fits[placement] && fits[opp[placement]]) ? opp[placement] : placement;
34
+ }
35
+ /**
36
+ * Compute `left` / `top` for a `position: fixed` floating element anchored to a trigger.
37
+ * Handles placement resolution (auto + flip), cross-axis alignment, viewport clamping,
38
+ * and optional arrow offset calculation.
39
+ */
40
+ function computePosition(trigger, floating, opts) {
41
+ const offset = opts.offset ?? 8;
42
+ const margin = opts.margin ?? 8;
43
+ const align = opts.align ?? 'center';
44
+ const placement = opts.placement === 'auto'
45
+ ? bestPlacement(trigger, floating, offset)
46
+ : maybeFlip(opts.placement, trigger, floating, offset);
47
+ // Main-axis offset
48
+ let left = 0, top = 0;
49
+ switch (placement) {
50
+ case 'top':
51
+ top = trigger.top - floating.height - offset;
52
+ break;
53
+ case 'bottom':
54
+ top = trigger.bottom + offset;
55
+ break;
56
+ case 'left':
57
+ left = trigger.left - floating.width - offset;
58
+ break;
59
+ case 'right':
60
+ left = trigger.right + offset;
61
+ break;
62
+ }
63
+ // Cross-axis alignment
64
+ if (placement === 'top' || placement === 'bottom') {
65
+ switch (align) {
66
+ case 'start':
67
+ left = trigger.left;
68
+ break;
69
+ case 'center':
70
+ left = trigger.left + (trigger.width - floating.width) / 2;
71
+ break;
72
+ case 'end':
73
+ left = trigger.right - floating.width;
74
+ break;
75
+ }
76
+ }
77
+ else {
78
+ switch (align) {
79
+ case 'start':
80
+ top = trigger.top;
81
+ break;
82
+ case 'center':
83
+ top = trigger.top + (trigger.height - floating.height) / 2;
84
+ break;
85
+ case 'end':
86
+ top = trigger.bottom - floating.height;
87
+ break;
88
+ }
89
+ }
90
+ // Clamp to viewport
91
+ const l = Math.max(margin, Math.min(window.innerWidth - floating.width - margin, left));
92
+ const t = Math.max(margin, Math.min(window.innerHeight - floating.height - margin, top));
93
+ // Arrow offset: keep arrow centred on the trigger even after viewport clamping
94
+ let arrowOffset;
95
+ if (opts.arrowSize !== undefined) {
96
+ const minOff = opts.arrowSize + 8; // min distance from rounded corner
97
+ if (placement === 'top' || placement === 'bottom') {
98
+ const raw = trigger.left + trigger.width / 2 - l;
99
+ arrowOffset = Math.max(minOff, Math.min(floating.width - minOff, raw));
100
+ }
101
+ else {
102
+ const raw = trigger.top + trigger.height / 2 - t;
103
+ arrowOffset = Math.max(minOff, Math.min(floating.height - minOff, raw));
104
+ }
105
+ }
106
+ return { left: l, top: t, placement, arrowOffset };
107
+ }
108
+ export { computePosition, bestPlacement, maybeFlip };
package/js/position.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Shared floating-element positioning utility.
3
+ * Used by Tooltip and Popover.
4
+ */
5
+
6
+ type Placement = 'top' | 'bottom' | 'left' | 'right';
7
+ type Align = 'start' | 'center' | 'end';
8
+
9
+ interface PositionOptions {
10
+ placement: Placement | 'auto';
11
+ align?: Align;
12
+ offset?: number; // gap between trigger and floating element (default: 8)
13
+ margin?: number; // minimum distance from viewport edge (default: 8)
14
+ arrowSize?: number; // if set, returns arrowOffset in result
15
+ }
16
+
17
+ interface PositionResult {
18
+ left: number;
19
+ top: number;
20
+ placement: Placement; // resolved (after auto/flip)
21
+ arrowOffset?: number; // px offset for arrow centering on trigger axis
22
+ }
23
+
24
+ /** Pick the placement with the most available space, preferring bottom > top > right > left. */
25
+ function bestPlacement(trigger: DOMRect, floating: DOMRect, offset: number): Placement {
26
+ const space = {
27
+ bottom: window.innerHeight - trigger.bottom - offset,
28
+ top: trigger.top - offset,
29
+ right: window.innerWidth - trigger.right - offset,
30
+ left: trigger.left - offset,
31
+ };
32
+ if (space.bottom >= floating.height) return 'bottom';
33
+ if (space.top >= floating.height) return 'top';
34
+ if (space.right >= floating.width) return 'right';
35
+ if (space.left >= floating.width) return 'left';
36
+ // Fallback: largest available side
37
+ return (Object.entries(space).sort((a, b) => b[1] - a[1])[0][0]) as Placement;
38
+ }
39
+
40
+ /** Flip to opposite side if preferred placement doesn't fit. */
41
+ function maybeFlip(placement: Placement, trigger: DOMRect, floating: DOMRect, offset: number): Placement {
42
+ const fits: Record<Placement, boolean> = {
43
+ top: trigger.top - offset >= floating.height,
44
+ bottom: window.innerHeight - trigger.bottom - offset >= floating.height,
45
+ left: trigger.left - offset >= floating.width,
46
+ right: window.innerWidth - trigger.right - offset >= floating.width,
47
+ };
48
+ const opp: Record<Placement, Placement> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
49
+ return (!fits[placement] && fits[opp[placement]]) ? opp[placement] : placement;
50
+ }
51
+
52
+ /**
53
+ * Compute `left` / `top` for a `position: fixed` floating element anchored to a trigger.
54
+ * Handles placement resolution (auto + flip), cross-axis alignment, viewport clamping,
55
+ * and optional arrow offset calculation.
56
+ */
57
+ function computePosition(trigger: DOMRect, floating: DOMRect, opts: PositionOptions): PositionResult {
58
+ const offset = opts.offset ?? 8;
59
+ const margin = opts.margin ?? 8;
60
+ const align = opts.align ?? 'center';
61
+
62
+ const placement: Placement = opts.placement === 'auto'
63
+ ? bestPlacement(trigger, floating, offset)
64
+ : maybeFlip(opts.placement, trigger, floating, offset);
65
+
66
+ // Main-axis offset
67
+ let left = 0, top = 0;
68
+ switch (placement) {
69
+ case 'top': top = trigger.top - floating.height - offset; break;
70
+ case 'bottom': top = trigger.bottom + offset; break;
71
+ case 'left': left = trigger.left - floating.width - offset; break;
72
+ case 'right': left = trigger.right + offset; break;
73
+ }
74
+
75
+ // Cross-axis alignment
76
+ if (placement === 'top' || placement === 'bottom') {
77
+ switch (align) {
78
+ case 'start': left = trigger.left; break;
79
+ case 'center': left = trigger.left + (trigger.width - floating.width) / 2; break;
80
+ case 'end': left = trigger.right - floating.width; break;
81
+ }
82
+ } else {
83
+ switch (align) {
84
+ case 'start': top = trigger.top; break;
85
+ case 'center': top = trigger.top + (trigger.height - floating.height) / 2; break;
86
+ case 'end': top = trigger.bottom - floating.height; break;
87
+ }
88
+ }
89
+
90
+ // Clamp to viewport
91
+ const l = Math.max(margin, Math.min(window.innerWidth - floating.width - margin, left));
92
+ const t = Math.max(margin, Math.min(window.innerHeight - floating.height - margin, top));
93
+
94
+ // Arrow offset: keep arrow centred on the trigger even after viewport clamping
95
+ let arrowOffset: number | undefined;
96
+ if (opts.arrowSize !== undefined) {
97
+ const minOff = opts.arrowSize + 8; // min distance from rounded corner
98
+ if (placement === 'top' || placement === 'bottom') {
99
+ const raw = trigger.left + trigger.width / 2 - l;
100
+ arrowOffset = Math.max(minOff, Math.min(floating.width - minOff, raw));
101
+ } else {
102
+ const raw = trigger.top + trigger.height / 2 - t;
103
+ arrowOffset = Math.max(minOff, Math.min(floating.height - minOff, raw));
104
+ }
105
+ }
106
+
107
+ return { left: l, top: t, placement, arrowOffset };
108
+ }
109
+
110
+ export { computePosition, bestPlacement, maybeFlip };
111
+ export type { Placement, Align, PositionOptions, PositionResult };