@marianmeres/stuic 1.15.0 → 1.17.0

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.
@@ -0,0 +1,21 @@
1
+ declare type Subscribe<T> = (value: T) => void;
2
+ declare type Unsubscribe = () => void;
3
+ declare type Update<T> = (value: T) => T;
4
+ interface StoreReadable<T> {
5
+ subscribe(cb: Subscribe<T>): Unsubscribe;
6
+ }
7
+ interface StoreLike<T> extends StoreReadable<T> {
8
+ set(value: T): void;
9
+ update(cb: Update<T>): void;
10
+ }
11
+ export type AutoscrollOptions = ScrollOptions & {
12
+ dependencies?: StoreReadable<any>[];
13
+ logger?: (...args: any[]) => void;
14
+ newScrollableContentSignal?: StoreLike<boolean>;
15
+ shouldScrollThresholdPx?: number;
16
+ startScrollTimeout?: number;
17
+ };
18
+ export declare function autoscroll(node: HTMLElement, options?: AutoscrollOptions): {
19
+ destroy(): void;
20
+ };
21
+ export {};
@@ -0,0 +1,60 @@
1
+ const DEFAULTS = {
2
+ shouldScrollThresholdPx: 100,
3
+ startScrollTimeout: 300,
4
+ };
5
+ export function autoscroll(node, options = {
6
+ shouldScrollThresholdPx: DEFAULTS.shouldScrollThresholdPx,
7
+ startScrollTimeout: DEFAULTS.startScrollTimeout,
8
+ }) {
9
+ // use "smooth" by default
10
+ options.behavior ??= 'smooth';
11
+ options.shouldScrollThresholdPx ??= DEFAULTS.shouldScrollThresholdPx;
12
+ options.startScrollTimeout ??= DEFAULTS.startScrollTimeout;
13
+ const { behavior, shouldScrollThresholdPx, dependencies, logger, newScrollableContentSignal, startScrollTimeout, } = options || {};
14
+ let origScrollHeight = 0;
15
+ const log = (...args) => typeof logger === 'function' && logger.apply(null, [...args]);
16
+ const shouldScroll = () => {
17
+ const { scrollTop, clientHeight } = node;
18
+ const result = origScrollHeight - scrollTop - clientHeight < shouldScrollThresholdPx;
19
+ log('shouldScroll?', result, { scrollTop, origScrollHeight, clientHeight });
20
+ return result;
21
+ };
22
+ const scroll = () => {
23
+ const opts = { top: node.scrollHeight, left: node.scrollWidth, behavior };
24
+ log(`scrollTo(${JSON.stringify(opts)})`);
25
+ node.scrollTo(opts);
26
+ };
27
+ // for when children change sizes
28
+ const resizeObserver = new ResizeObserver(() => {
29
+ log('observed resize...');
30
+ shouldScroll() && scroll();
31
+ });
32
+ // for when children
33
+ const mutationObserver = new MutationObserver(() => {
34
+ log('observed mutation...');
35
+ shouldScroll() ? scroll() : newScrollableContentSignal?.set(true);
36
+ origScrollHeight = node.scrollHeight;
37
+ });
38
+ const unsubs = dependencies?.map((dep) => dep.subscribe((v) => {
39
+ log('dependency update...', v);
40
+ setTimeout(scroll, startScrollTimeout);
41
+ })) ?? [];
42
+ // observe size of all children
43
+ for (const child of node.children) {
44
+ resizeObserver.observe(child);
45
+ }
46
+ mutationObserver.observe(node, { childList: true, subtree: true });
47
+ return {
48
+ destroy() {
49
+ if (mutationObserver) {
50
+ mutationObserver.disconnect();
51
+ }
52
+ if (resizeObserver) {
53
+ resizeObserver.disconnect();
54
+ }
55
+ for (const unsubscribe of unsubs) {
56
+ unsubscribe();
57
+ }
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,9 @@
1
+ type Handler = (el: Node) => void;
2
+ export interface OnOutsideOptions {
3
+ handler?: Handler | null;
4
+ events?: string[];
5
+ }
6
+ export declare const onOutside: (node: HTMLElement, options?: OnOutsideOptions) => {
7
+ destroy(): void;
8
+ };
9
+ export {};
@@ -0,0 +1,27 @@
1
+ // will notify and (optionally) execute on outside click/focusin/...
2
+ export const onOutside = (node, options) => {
3
+ const DEFAULT_OPTIONS = {
4
+ handler: null,
5
+ events: ['click', 'focusin'],
6
+ };
7
+ const { handler, events } = { ...DEFAULT_OPTIONS, ...(options || {}) };
8
+ const listener = (event) => {
9
+ if (!event?.target)
10
+ return;
11
+ if (node && !node.contains(event.target) && !event.defaultPrevented) {
12
+ node.dispatchEvent(new CustomEvent('outside', { detail: event.target }));
13
+ if (typeof handler === 'function')
14
+ handler(event.target);
15
+ }
16
+ };
17
+ events?.forEach((eventName) => {
18
+ document.addEventListener(eventName, listener, true);
19
+ });
20
+ return {
21
+ destroy() {
22
+ events?.forEach((eventName) => {
23
+ document.removeEventListener(eventName, listener, true);
24
+ });
25
+ },
26
+ };
27
+ };
@@ -0,0 +1,3 @@
1
+ import { type TooltipLogger } from './tooltip.js';
2
+ export declare const _makeInVisible: (div: HTMLElement | null, arrow: HTMLElement | null, log: TooltipLogger) => void;
3
+ export declare const _makeVisible: (div: HTMLElement | null, arrow: HTMLElement | null, log: TooltipLogger) => void;
@@ -0,0 +1,25 @@
1
+ import { _TRANSITION_OPACITY_DUR } from './tooltip.js';
2
+ export const _makeInVisible = (div, arrow, log) => {
3
+ (div || arrow) && log('_makeInVisible');
4
+ div && (div.style.opacity = '0');
5
+ arrow && (arrow.style.opacity = '0');
6
+ setTimeout(() => {
7
+ arrow?.classList.add('hidden');
8
+ div?.classList.add('hidden');
9
+ if (div) {
10
+ div.style.left = `auto`;
11
+ div.style.top = `auto`;
12
+ }
13
+ }, _TRANSITION_OPACITY_DUR);
14
+ };
15
+ export const _makeVisible = (div, arrow, log) => {
16
+ (div || arrow) && log('_makeVisible');
17
+ if (div) {
18
+ div.classList.remove('hidden');
19
+ div.style.opacity = '1';
20
+ }
21
+ if (arrow) {
22
+ arrow.classList.remove('hidden');
23
+ arrow.style.opacity = '1';
24
+ }
25
+ };
@@ -0,0 +1,4 @@
1
+ import type { calculateAlignment } from '../../index.js';
2
+ import type { Alignment } from '../../utils/calculate-alignment.js';
3
+ import type { TooltipLogger, TooltipOptions } from './tooltip.js';
4
+ export declare const _maybePickSafePlacement: (calc: ReturnType<typeof calculateAlignment>, opts: TooltipOptions, log: TooltipLogger) => Alignment | false;
@@ -0,0 +1,86 @@
1
+ export const _maybePickSafePlacement = (calc, opts, log) => {
2
+ let preferred = opts.alignment;
3
+ let picked = preferred;
4
+ const sides = {
5
+ top: ['top', 'topLeft', 'topRight'],
6
+ bottom: ['bottom', 'bottomLeft', 'bottomRight'],
7
+ left: ['left', 'leftTop', 'leftBottom'],
8
+ right: ['right', 'rightTop', 'rightBottom'],
9
+ };
10
+ // try alternatives within same side
11
+ const _trySideVariant = (val) => {
12
+ for (let side of Object.keys(sides)) {
13
+ if (val.startsWith(side)) {
14
+ for (let pos of sides[side]) {
15
+ if (calc.position[pos].safe) {
16
+ log(`_trySideVariant for '${val}', found '${pos}'`);
17
+ return pos;
18
+ }
19
+ }
20
+ }
21
+ }
22
+ log(`_trySideVariant for '${val}', found none (returning orig '${val}')`);
23
+ return val;
24
+ };
25
+ // DRY
26
+ const _trySideOpposite = (val) => {
27
+ const opposites = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
28
+ for (let [k, v] of Object.entries(opposites)) {
29
+ if (val.startsWith(k)) {
30
+ const r = val.replace(k, v);
31
+ log(`_trySideOpposite for '${val}', found '${r}'`);
32
+ return r;
33
+ }
34
+ }
35
+ log(`_trySideOpposite for '${val}', found none (returning orig '${val}')`);
36
+ return val;
37
+ };
38
+ const _switchAxis = (val) => {
39
+ const opposites = { top: 'right', bottom: 'right', left: 'top', right: 'top' };
40
+ for (let [k, v] of Object.entries(opposites)) {
41
+ if (val.startsWith(k)) {
42
+ log(`_switchAxis for '${val}', found '${v}'`);
43
+ return v;
44
+ }
45
+ }
46
+ log(`_switchAxis for '${val}', found none (returning orig '${val}')`);
47
+ return val;
48
+ };
49
+ // kind of stupid, brute force approach...
50
+ // same side variant
51
+ if (!calc.position[picked].safe) {
52
+ picked = _trySideVariant(picked);
53
+ }
54
+ // oposite side
55
+ if (!calc.position[picked].safe) {
56
+ picked = _trySideOpposite(picked);
57
+ // oposite side variant
58
+ if (!calc.position[picked].safe) {
59
+ picked = _trySideVariant(picked);
60
+ }
61
+ }
62
+ // switch axis
63
+ if (!calc.position[picked].safe) {
64
+ picked = _switchAxis(picked);
65
+ // now second round
66
+ // same side variant
67
+ if (!calc.position[picked].safe) {
68
+ picked = _trySideVariant(picked);
69
+ }
70
+ // oposite side
71
+ if (!calc.position[picked].safe) {
72
+ picked = _trySideOpposite(picked);
73
+ // oposite side variant
74
+ if (!calc.position[picked].safe) {
75
+ picked = _trySideVariant(picked);
76
+ }
77
+ }
78
+ }
79
+ // finally, if still no luck, revert to
80
+ // a) either unsafe original (to avoid noise) and do not dance anymore, or
81
+ // b) hide (if configured so)
82
+ if (!calc.position[picked].safe) {
83
+ picked = opts.hideOnInsufficientSpace ? false : preferred;
84
+ }
85
+ return picked;
86
+ };
@@ -0,0 +1,2 @@
1
+ import type { TooltipLogger, TooltipOptions } from './tooltip.js';
2
+ export declare const _setPosition: (boundaryRoot: HTMLElement | undefined, parent: HTMLElement, div: HTMLElement | null, arrow: HTMLElement | null, opts: TooltipOptions, log: TooltipLogger) => Promise<unknown>;
@@ -0,0 +1,125 @@
1
+ import { get as storeGet } from 'svelte/store';
2
+ import { calculateAlignment, windowSize } from '../../index.js';
3
+ import { _maybePickSafePlacement } from './_maybe-pick-safe-placement.js';
4
+ export const _setPosition = async (boundaryRoot, // will default to window dimensions
5
+ parent, div, arrow, opts, log) => {
6
+ // if (!div || !arrow) return log('_setPosition noop');
7
+ if (!div || !arrow)
8
+ return;
9
+ log('_setPosition');
10
+ const rootRect = boundaryRoot?.getBoundingClientRect() || {
11
+ ...storeGet(windowSize),
12
+ x: 0,
13
+ y: 0,
14
+ };
15
+ const boundaryRootRect = {
16
+ x: rootRect.x,
17
+ y: rootRect.y,
18
+ width: rootRect.width,
19
+ height: rootRect.height,
20
+ };
21
+ const parentRect = parent.getBoundingClientRect();
22
+ // IMPORTANT!
23
+ // make sure the div is not hidden, so the rect dimensions will work fine
24
+ div.classList.remove('hidden');
25
+ return new Promise((resolve) => {
26
+ // must wait for the next repaint to have correct dimensions...
27
+ requestAnimationFrame(() => {
28
+ const divRect = div.getBoundingClientRect();
29
+ const r = calculateAlignment(boundaryRootRect, parentRect, divRect, opts.offset);
30
+ log('tooltip rect', divRect);
31
+ log('placements', r);
32
+ // try to pick safe
33
+ const safe = _maybePickSafePlacement(r, opts, log);
34
+ // maybe quit...
35
+ if (safe === false) {
36
+ log('No safe position found...');
37
+ return resolve(false);
38
+ }
39
+ log(`Preferred: '${opts.alignment}', safe: '${safe}'.`);
40
+ // position the actual tooltip/popover
41
+ div.style.left = `${r.position[safe].x}px`;
42
+ div.style.top = `${r.position[safe].y}px`;
43
+ // div.style.left = `${0}px`;
44
+ // div.style.top = `${0}px`;
45
+ // now dance with the arrow...
46
+ let arrowStyles = {
47
+ borderStyle: 'solid',
48
+ left: 'auto',
49
+ top: 'auto',
50
+ };
51
+ // need to reset all on reposition
52
+ ['borderTop', 'borderBottom', 'borderLeft', 'borderRight'].forEach((k) => {
53
+ arrowStyles[`${k}Width`] = '0';
54
+ arrowStyles[`${k}Color`] = null;
55
+ });
56
+ //
57
+ const arrowSize = opts.arrowSize;
58
+ let xOffset = 0;
59
+ let yOffset = 0;
60
+ //
61
+ if (safe.startsWith('top')) {
62
+ arrowStyles = {
63
+ ...arrowStyles,
64
+ borderLeftColor: `transparent`,
65
+ borderRightColor: `transparent`,
66
+ borderLeftWidth: `${arrowSize * 0.75}px`,
67
+ borderRightWidth: `${arrowSize * 0.75}px`,
68
+ borderTopWidth: `${arrowSize}px`,
69
+ };
70
+ xOffset -= arrowSize / 2;
71
+ yOffset -= arrowSize * 0.1;
72
+ }
73
+ //
74
+ else if (safe.startsWith('bottom')) {
75
+ arrowStyles = {
76
+ ...arrowStyles,
77
+ borderLeftColor: `transparent`,
78
+ borderRightColor: `transparent`,
79
+ borderLeftWidth: `${arrowSize * 0.75}px`,
80
+ borderRightWidth: `${arrowSize * 0.75}px`,
81
+ borderBottomWidth: `${arrowSize}px`,
82
+ };
83
+ xOffset -= arrowSize / 2;
84
+ yOffset -= arrowSize * 0.9;
85
+ }
86
+ //
87
+ else if (safe.startsWith('right')) {
88
+ arrowStyles = {
89
+ ...arrowStyles,
90
+ borderTopColor: `transparent`,
91
+ borderBottomColor: `transparent`,
92
+ borderTopWidth: `${arrowSize * 0.75}px`,
93
+ borderBottomWidth: `${arrowSize * 0.75}px`,
94
+ borderRightWidth: `${arrowSize}px`,
95
+ };
96
+ xOffset -= arrowSize * 0.9;
97
+ yOffset -= arrowSize / 2;
98
+ }
99
+ //
100
+ else if (safe.startsWith('left')) {
101
+ arrowStyles = {
102
+ ...arrowStyles,
103
+ borderTopColor: `transparent`,
104
+ borderBottomColor: `transparent`,
105
+ borderTopWidth: `${arrowSize * 0.75}px`,
106
+ borderBottomWidth: `${arrowSize * 0.75}px`,
107
+ borderLeftWidth: `${arrowSize}px`,
108
+ };
109
+ xOffset -= arrowSize * 0.1;
110
+ yOffset -= arrowSize / 2;
111
+ }
112
+ arrowStyles = {
113
+ ...arrowStyles,
114
+ left: `${r.origin[safe].x + xOffset}px`,
115
+ top: `${r.origin[safe].y + yOffset}px`,
116
+ };
117
+ // log('applying arrowStyles', arrowStyles);
118
+ Object.entries(arrowStyles).forEach(([k, v]) => {
119
+ arrow.style[k] = v;
120
+ });
121
+ //
122
+ resolve(true);
123
+ });
124
+ });
125
+ };
@@ -0,0 +1,41 @@
1
+ /// <reference types="svelte" />
2
+ import { type Readable, type Writable } from 'svelte/store';
3
+ import type { Alignment } from '../../utils/calculate-alignment.js';
4
+ export declare class TooltipConfig {
5
+ static presetBase: string;
6
+ static class: string;
7
+ static arrowPresetBase: string;
8
+ static arrowClass: string;
9
+ static defaultOptions: Partial<TooltipOptions>;
10
+ }
11
+ export declare class PopoverConfig {
12
+ static presetBase: string;
13
+ static class: string;
14
+ static arrowPresetBase: string;
15
+ static arrowClass: string;
16
+ static defaultOptions: Partial<TooltipOptions>;
17
+ }
18
+ export type TooltipLogger = (...args: any[]) => void;
19
+ export interface TooltipOptions {
20
+ content: string;
21
+ popover: HTMLElement | null;
22
+ alignment: Alignment;
23
+ allowHtml: boolean;
24
+ delay: number;
25
+ class: string;
26
+ arrowClass: string;
27
+ triggers: string[];
28
+ logger?: TooltipLogger;
29
+ boundaryRoot?: HTMLElement;
30
+ arrowSize: number;
31
+ offset: number;
32
+ hideOnInsufficientSpace: boolean;
33
+ touch?: Readable<number>;
34
+ trigger?: Readable<boolean>;
35
+ notifier?: Writable<boolean>;
36
+ }
37
+ export declare const _TRANSITION_OPACITY_DUR = 150;
38
+ export declare function tooltip(node: HTMLElement, initialOptions?: string | Partial<TooltipOptions>): {
39
+ update(newOptions: string | Partial<TooltipOptions>): void;
40
+ destroy(): void;
41
+ };
@@ -0,0 +1,296 @@
1
+ import { createStore } from '@marianmeres/store';
2
+ import { derived, get as storeGet, writable, } from 'svelte/store';
3
+ import { twMerge } from 'tailwind-merge';
4
+ import { windowSize } from '../../index.js';
5
+ import { _makeInVisible, _makeVisible } from './_make-visible.js';
6
+ import { _setPosition } from './_set-position.js';
7
+ export class TooltipConfig {
8
+ static presetBase = `
9
+ text-sm
10
+ bg-gray-950 dark:bg-gray-950
11
+ text-gray-50 dark:text-gray-50
12
+ px-3 py-2
13
+ rounded-md
14
+ shadow-lg
15
+ z-50
16
+ max-w-96 w-max
17
+ `;
18
+ static class = '';
19
+ static arrowPresetBase = `border-gray-950 dark:border-gray-950 z-50`;
20
+ static arrowClass = ``;
21
+ static defaultOptions = {};
22
+ }
23
+ export class PopoverConfig {
24
+ static presetBase = `
25
+ text-sm
26
+ bg-gray-950 dark:bg-gray-950
27
+ text-gray-50 dark:text-gray-50
28
+ px-3 py-2
29
+ rounded-md
30
+ shadow-lg
31
+ z-50
32
+ `;
33
+ // max-w-96 w-max
34
+ static class = '';
35
+ static arrowPresetBase = `border-gray-950 dark:border-gray-950 z-50`;
36
+ static arrowClass = ``;
37
+ static defaultOptions = {};
38
+ }
39
+ const DEFAULTS = {
40
+ content: '',
41
+ popover: null,
42
+ alignment: 'top',
43
+ allowHtml: true,
44
+ delay: 300,
45
+ triggers: ['hover'], // 'focus', 'click'
46
+ class: '',
47
+ arrowClass: '',
48
+ arrowSize: 8,
49
+ offset: 10,
50
+ hideOnInsufficientSpace: false,
51
+ };
52
+ const TRIGGERS = {
53
+ hover: { show: ['mouseover'], hide: ['mouseout'] },
54
+ focus: { show: ['focusin'], hide: ['blur'] },
55
+ click: { show: ['click'], hide: [] }, // no hide for click, will toggle instead
56
+ };
57
+ // default TW value
58
+ export const _TRANSITION_OPACITY_DUR = 150;
59
+ const _ensureDiv = (div, opts, log) => {
60
+ log('_ensureDiv');
61
+ if (!div) {
62
+ log('creating tooltip div...');
63
+ div = document.createElement('div');
64
+ document.body.appendChild(div);
65
+ }
66
+ else {
67
+ log('div exists... going to apply classes');
68
+ }
69
+ let classes = '';
70
+ if (opts.popover) {
71
+ classes = [PopoverConfig.presetBase, PopoverConfig.class, opts.class].join(' ');
72
+ }
73
+ else {
74
+ classes = [TooltipConfig.presetBase, TooltipConfig.class, opts.class].join(' ');
75
+ }
76
+ // make sure these are never overwritten (must come last)
77
+ classes = twMerge(classes, 'fixed block transition-opacity')
78
+ .split(/\s/)
79
+ .filter(Boolean);
80
+ div.classList.add(...classes);
81
+ div.style.opacity = '0';
82
+ // log(`Div classes applied (+ opacity 0)`, classes);
83
+ return div;
84
+ };
85
+ const _ensureArrow = (arrow, opts, log) => {
86
+ log('_ensureArrow');
87
+ if (!arrow) {
88
+ log('creating tooltip arrow...');
89
+ arrow = document.createElement('div');
90
+ document.body.appendChild(arrow);
91
+ }
92
+ else {
93
+ log('arrow exists... going to apply classes');
94
+ }
95
+ let classes = '';
96
+ // prettier-ignore
97
+ if (opts.popover) {
98
+ classes = [PopoverConfig.arrowPresetBase, PopoverConfig.arrowClass, opts.arrowClass].join(' ');
99
+ }
100
+ else {
101
+ classes = [TooltipConfig.arrowPresetBase, TooltipConfig.arrowClass, opts.arrowClass].join(' ');
102
+ }
103
+ // make sure these are never overwritten (must come last)
104
+ classes = twMerge(classes, 'fixed block size-0 transition-opacity')
105
+ .split(/\s/)
106
+ .filter(Boolean);
107
+ arrow.classList.add(...classes);
108
+ arrow.style.opacity = '0';
109
+ // log(`Arrow classes applied (+ opacity 0)`, classes);
110
+ return arrow;
111
+ };
112
+ // the action api
113
+ export function tooltip(node, initialOptions = {}) {
114
+ if (typeof initialOptions === 'string')
115
+ initialOptions = { content: initialOptions };
116
+ //
117
+ initialOptions ??= {};
118
+ let defaults = DEFAULTS;
119
+ if (initialOptions.popover) {
120
+ defaults = { ...defaults, ...PopoverConfig.defaultOptions };
121
+ }
122
+ else {
123
+ defaults = { ...defaults, ...TooltipConfig.defaultOptions };
124
+ }
125
+ let opts = { ...defaults, ...initialOptions };
126
+ const content = createStore(opts.content || '');
127
+ // maybe use aria-label (if present and content is not set)
128
+ const ariaLabel = node.getAttribute('aria-label');
129
+ if (ariaLabel && !content.get())
130
+ content.set(ariaLabel);
131
+ const _log = (...args) => typeof opts.logger === 'function' &&
132
+ opts.logger.apply(null, [`[tooltip/${node.id || '?'}]`, ...args]);
133
+ //
134
+ let _delayTimer;
135
+ const _resetDelayTimer = () => {
136
+ if (_delayTimer)
137
+ clearTimeout(_delayTimer);
138
+ _delayTimer = null;
139
+ };
140
+ const _planDelayedExec = (_fn, _delay) => {
141
+ _resetDelayTimer();
142
+ _delayTimer = setTimeout(() => {
143
+ _fn();
144
+ _resetDelayTimer();
145
+ }, _delay);
146
+ };
147
+ // use popover if provided, otherwise new div will be createed
148
+ let div = opts.popover;
149
+ let arrow;
150
+ let _isOn = writable(false); // internal state store
151
+ //
152
+ const _show = async () => {
153
+ _log('_show');
154
+ // return early on no content
155
+ if (!opts.popover && !content.get()) {
156
+ _log('Nothing to show (neither popover, nor content provided)');
157
+ return;
158
+ }
159
+ // create, apply core styles and append to DOM
160
+ div = _ensureDiv(div, opts, _log);
161
+ arrow = _ensureArrow(arrow, opts, _log);
162
+ // set content
163
+ if (!opts.popover) {
164
+ if (opts.allowHtml)
165
+ div.innerHTML = content.get();
166
+ else
167
+ div.textContent = content.get();
168
+ }
169
+ // measure stuff and set position (provided opts.alignment is considered just as
170
+ // "preferred", which means it may be overwritten if there is no available space)
171
+ if (!(await _setPosition(opts.boundaryRoot, node, div, arrow, opts, _log))) {
172
+ _makeInVisible(div, arrow, _log);
173
+ }
174
+ else {
175
+ // finally, fade in
176
+ _makeVisible(div, arrow, _log);
177
+ setTimeout(() => _isOn.set(true), _TRANSITION_OPACITY_DUR);
178
+ }
179
+ };
180
+ let show = () => _planDelayedExec(_show, opts.delay);
181
+ //
182
+ const _hide = async () => {
183
+ _log('_hide');
184
+ _makeInVisible(div, arrow, _log);
185
+ setTimeout(() => {
186
+ destroy();
187
+ _isOn.set(false);
188
+ }, _TRANSITION_OPACITY_DUR);
189
+ };
190
+ const hide = () => _planDelayedExec(_hide, opts.delay);
191
+ // no delay for toggle
192
+ const toggle = async () => {
193
+ _log('toggle');
194
+ storeGet(_isOn) ? await _hide() : await _show();
195
+ };
196
+ //
197
+ const destroy = () => {
198
+ if (!div && !arrow && !opts.popover && !storeGet(_isOn))
199
+ return;
200
+ _log('destroy');
201
+ if (!opts.popover) {
202
+ div?.remove();
203
+ div = null;
204
+ }
205
+ arrow?.remove();
206
+ arrow = null;
207
+ _isOn.set(false);
208
+ };
209
+ //
210
+ let unsubs = [_isOn.subscribe((v) => opts?.notifier?.set(v))];
211
+ // by default, listen to windowSize change, as well as window and boundaryRoot scroll
212
+ const _boundaryRootScroll = writable(0);
213
+ const onScroll = () => _boundaryRootScroll.set(Date.now());
214
+ if (opts.boundaryRoot) {
215
+ opts.boundaryRoot.addEventListener('scroll', onScroll);
216
+ unsubs.push(() => opts.boundaryRoot?.removeEventListener('scroll', onScroll));
217
+ }
218
+ // also listen to window scroll
219
+ window.addEventListener('scroll', onScroll);
220
+ unsubs.push(() => window.removeEventListener('scroll', onScroll));
221
+ const _positionTriggers = [_boundaryRootScroll, windowSize];
222
+ if (opts.touch?.subscribe)
223
+ _positionTriggers.push(opts.touch);
224
+ const touch = derived(_positionTriggers, ([_]) => Date.now());
225
+ // final, derived, notifier
226
+ let _touchCount = 0;
227
+ unsubs.push(touch.subscribe(async () => {
228
+ // ignore first
229
+ if (_touchCount++) {
230
+ _log('touch...');
231
+ if (!(await _setPosition(opts.boundaryRoot, node, div, arrow, opts, _log))) {
232
+ _makeInVisible(div, arrow, _log);
233
+ }
234
+ else {
235
+ _makeVisible(div, arrow, _log);
236
+ }
237
+ }
238
+ }));
239
+ // important to normalize triggers - if does contain 'click' remove all others
240
+ if (opts.triggers.includes('click')) {
241
+ _log('Click trigger recognized...');
242
+ opts.triggers = ['click'];
243
+ _log('Patching show as toggle');
244
+ show = toggle; // also patch show/hide logic, since click does not have 'out' event...
245
+ }
246
+ // if manual trigger exists... (use raw show/hide, not the delayed one)
247
+ if (opts.trigger?.subscribe) {
248
+ unsubs.push(opts.trigger.subscribe((v) => (v ? _show() : _hide())));
249
+ }
250
+ // add show
251
+ opts.triggers?.forEach((trigger) => {
252
+ TRIGGERS[trigger]?.show.forEach((eventName) => {
253
+ _log('addEventListener', eventName, 'show');
254
+ node.addEventListener(eventName, show);
255
+ });
256
+ });
257
+ // add hide
258
+ opts.triggers?.forEach((trigger) => {
259
+ TRIGGERS[trigger]?.hide.forEach((eventName) => {
260
+ _log('addEventListener', eventName, 'hide');
261
+ node.addEventListener(eventName, hide);
262
+ });
263
+ });
264
+ return {
265
+ //
266
+ update(newOptions) {
267
+ if (typeof newOptions === 'string')
268
+ newOptions = { content: newOptions };
269
+ destroy();
270
+ // these are not allowed to update on existing instance
271
+ delete newOptions.triggers;
272
+ delete newOptions.boundaryRoot;
273
+ delete newOptions.touch;
274
+ //
275
+ opts = { ...opts, ...newOptions };
276
+ content.set(opts.content);
277
+ },
278
+ //
279
+ destroy() {
280
+ unsubs.forEach((unsub) => typeof unsub === 'function' && unsub());
281
+ destroy();
282
+ // remove show
283
+ opts.triggers?.forEach((trigger) => {
284
+ TRIGGERS[trigger]?.show.forEach((eventName) => {
285
+ node.removeEventListener(eventName, show);
286
+ });
287
+ });
288
+ // remove hide
289
+ opts.triggers?.forEach((trigger) => {
290
+ TRIGGERS[trigger]?.hide.forEach((eventName) => {
291
+ node.removeEventListener(eventName, hide);
292
+ });
293
+ });
294
+ },
295
+ };
296
+ }
@@ -3,7 +3,7 @@ import { createSwitchStore } from "@marianmeres/switch-store";
3
3
  import { createEventDispatcher } from "svelte";
4
4
  import { fly } from "svelte/transition";
5
5
  import { twMerge } from "tailwind-merge";
6
- import { clickOutside } from "../../actions/click-outside.js";
6
+ import { onOutside } from "../../actions/on-outside.js";
7
7
  import { prefersReducedMotionStore } from "../../utils/prefers-reduced-motion.js";
8
8
  import Backdrop from "../Backdrop/Backdrop.svelte";
9
9
  export const createDrawerStore = (open = false) => createSwitchStore(open);
@@ -78,7 +78,7 @@ $:
78
78
  ...(_presetsAnim[position] || {}),
79
79
  }}
80
80
  class={twMerge(`overflow-y-auto ${_presetsCls[position] || ''} ${_class}`)}
81
- use:clickOutside={() => dispatch('click_outside')}
81
+ use:onOutside
82
82
  >
83
83
  <slot />
84
84
  </div>
@@ -57,9 +57,7 @@ const _expand = () => {
57
57
  el.style.width = "auto";
58
58
  el.style.height = "auto";
59
59
  setTimeout(
60
- () => {
61
- _isExpanding = false;
62
- },
60
+ () => _isExpanding = false,
63
61
  duration + 1e3 / 60 * 3
64
62
  // 3 x raf
65
63
  );
@@ -11,7 +11,8 @@ export class SwitchConfig {
11
11
  hover:brightness-[1.05] active:brightness-[0.95]
12
12
  disabled:!cursor-not-allowed disabled:!opacity-50 disabled:hover:brightness-100
13
13
 
14
- bg-zinc-300 data-[checked=true]:bg-zinc-600
14
+ bg-zinc-300 dark:bg-zinc-700
15
+ data-[checked=true]:bg-zinc-700 dark:data-[checked=true]:bg-zinc-300
15
16
  `.trim();
16
17
  static presetsSize = {
17
18
  xs: "h-4 w-7",
@@ -29,7 +30,8 @@ export class SwitchConfig {
29
30
  translate-x-1 rounded-full
30
31
  transition-all duration-100
31
32
  shadow
32
- bg-white
33
+ bg-white dark:bg-black
34
+ text-black dark:text-white
33
35
  `.trim();
34
36
  static presetsSizeDot = {
35
37
  // size + translate-x = width
@@ -34,7 +34,7 @@ declare const __propDef: {
34
34
  variant?: string | undefined;
35
35
  stopPropagation?: boolean | undefined;
36
36
  preventDefault?: boolean | undefined;
37
- preHook?: ((oldValue: boolean) => Promise<void | boolean>) | undefined;
37
+ preHook?: ((previosValue: boolean) => Promise<void | boolean>) | undefined;
38
38
  size?: "xs" | "sm" | "md" | "lg" | "xl" | undefined;
39
39
  };
40
40
  events: {
@@ -0,0 +1,24 @@
1
+ <script>import { tooltip } from "../..";
2
+ export let options = {};
3
+ const POPOVER_DEFAULTS = {
4
+ hideOnInsufficientSpace: true,
5
+ triggers: ["click"]
6
+ };
7
+ let popover;
8
+ </script>
9
+
10
+ {#if popover}
11
+ <span
12
+ use:tooltip={{
13
+ ...POPOVER_DEFAULTS,
14
+ ...(options || {}),
15
+ popover,
16
+ }}
17
+ >
18
+ <slot />
19
+ </span>
20
+ {/if}
21
+
22
+ <div bind:this={popover} class="hidden">
23
+ <slot name="popover" />
24
+ </div>
@@ -0,0 +1,20 @@
1
+ import { SvelteComponent } from "svelte";
2
+ import type { TooltipOptions } from '../../actions/tooltip/tooltip.js';
3
+ declare const __propDef: {
4
+ props: {
5
+ options?: Partial<TooltipOptions> | undefined;
6
+ };
7
+ events: {
8
+ [evt: string]: CustomEvent<any>;
9
+ };
10
+ slots: {
11
+ default: {};
12
+ popover: {};
13
+ };
14
+ };
15
+ export type PopoverProps = typeof __propDef.props;
16
+ export type PopoverEvents = typeof __propDef.events;
17
+ export type PopoverSlots = typeof __propDef.slots;
18
+ export default class Popover extends SvelteComponent<PopoverProps, PopoverEvents, PopoverSlots> {
19
+ }
20
+ export {};
package/dist/index.d.ts CHANGED
@@ -8,7 +8,10 @@ export { default as Drawer, createDrawerStore } from './components/Drawer/Drawer
8
8
  export { default as HoverExpandableWidth } from './components/HoverExpandableWidth/HoverExpandableWidth.svelte';
9
9
  export { default as Switch } from './components/Switch/Switch.svelte';
10
10
  export { default as X } from './components/X/X.svelte';
11
- export { clickOutside } from './actions/click-outside.js';
12
11
  export { focusTrap } from './actions/focus-trap.js';
13
- export { windowSize, breakpoint } from './utils/window-size.js';
12
+ export { onOutside } from './actions/on-outside.js';
13
+ export { tooltip, TooltipConfig } from './actions/tooltip/tooltip.js';
14
+ export { calculateAlignment } from './utils/calculate-alignment.js';
14
15
  export { DevicePointer } from './utils/device-pointer.js';
16
+ export { getId } from './utils/get-id.js';
17
+ export { windowSize, breakpoint } from './utils/window-size.js';
package/dist/index.js CHANGED
@@ -18,8 +18,11 @@ export { default as Switch } from './components/Switch/Switch.svelte';
18
18
  //
19
19
  export { default as X } from './components/X/X.svelte';
20
20
  // actions
21
- export { clickOutside } from './actions/click-outside.js';
22
21
  export { focusTrap } from './actions/focus-trap.js';
22
+ export { onOutside } from './actions/on-outside.js';
23
+ export { tooltip, TooltipConfig } from './actions/tooltip/tooltip.js';
23
24
  // utils
24
- export { windowSize, breakpoint } from './utils/window-size.js';
25
+ export { calculateAlignment } from './utils/calculate-alignment.js';
25
26
  export { DevicePointer } from './utils/device-pointer.js';
27
+ export { getId } from './utils/get-id.js';
28
+ export { windowSize, breakpoint } from './utils/window-size.js';
@@ -0,0 +1,68 @@
1
+ export interface AreaLike {
2
+ width: number;
3
+ height: number;
4
+ }
5
+ export interface PositionLike {
6
+ x: number;
7
+ y: number;
8
+ }
9
+ export interface RectLike extends AreaLike, PositionLike {
10
+ }
11
+ export type Alignment = 'top' | 'topRight' | 'topLeft' | 'right' | 'rightTop' | 'rightBottom' | 'bottom' | 'bottomRight' | 'bottomLeft' | 'left' | 'leftTop' | 'leftBottom';
12
+ export declare const calculateAlignment: (boundaryRoot: RectLike, anchor: RectLike, el: AreaLike, offset?: number) => {
13
+ origin: {
14
+ top: {
15
+ x: number;
16
+ y: number;
17
+ };
18
+ topRight: {
19
+ x: number;
20
+ y: number;
21
+ };
22
+ topLeft: {
23
+ x: number;
24
+ y: number;
25
+ };
26
+ bottom: {
27
+ x: number;
28
+ y: number;
29
+ };
30
+ bottomRight: {
31
+ x: number;
32
+ y: number;
33
+ };
34
+ bottomLeft: {
35
+ x: number;
36
+ y: number;
37
+ };
38
+ left: {
39
+ x: number;
40
+ y: number;
41
+ };
42
+ leftTop: {
43
+ x: number;
44
+ y: number;
45
+ };
46
+ leftBottom: {
47
+ x: number;
48
+ y: number;
49
+ };
50
+ right: {
51
+ x: number;
52
+ y: number;
53
+ };
54
+ rightTop: {
55
+ x: number;
56
+ y: number;
57
+ };
58
+ rightBottom: {
59
+ x: number;
60
+ y: number;
61
+ };
62
+ };
63
+ position: Record<Alignment, PositionLike & {
64
+ safeY: boolean;
65
+ safeX: boolean;
66
+ safe: boolean;
67
+ }>;
68
+ };
@@ -0,0 +1,183 @@
1
+ const clog = console.log;
2
+ // just basic plus/minus stuff
3
+ export const calculateAlignment = (boundaryRoot,
4
+ // anchor box
5
+ anchor,
6
+ // element to be placed (the one we're calculating position for)
7
+ el,
8
+ // in px
9
+ offset = 5) => {
10
+ const anchorHalfWidth = anchor.width / 2;
11
+ const anchorHalfHeight = anchor.height / 2;
12
+ // x/y of the origin (center of the anchor with applied offset) position
13
+ const originTop = { x: anchor.x + anchorHalfWidth, y: anchor.y - offset };
14
+ const originBottom = {
15
+ x: anchor.x + anchorHalfWidth,
16
+ y: anchor.y + anchor.height + offset,
17
+ };
18
+ const originLeft = { x: anchor.x - offset, y: anchor.y + anchorHalfHeight };
19
+ const originRight = {
20
+ x: anchor.x + anchor.width + offset,
21
+ y: anchor.y + anchorHalfHeight,
22
+ };
23
+ const origin = {
24
+ top: originTop,
25
+ topRight: originTop,
26
+ topLeft: originTop,
27
+ //
28
+ bottom: originBottom,
29
+ bottomRight: originBottom,
30
+ bottomLeft: originBottom,
31
+ //
32
+ left: originLeft,
33
+ leftTop: originLeft,
34
+ leftBottom: originLeft,
35
+ //
36
+ right: originRight,
37
+ rightTop: originRight,
38
+ rightBottom: originRight,
39
+ };
40
+ const elHalfWidth = el.width / 2;
41
+ const elHalfHeigh = el.height / 2;
42
+ // position is named from the "anchor alignment" relative perspective
43
+ const position = {
44
+ //
45
+ top: {
46
+ x: origin.top.x - elHalfWidth,
47
+ y: origin.top.y - el.height,
48
+ safeX: true,
49
+ safeY: true,
50
+ safe: true,
51
+ },
52
+ topRight: {
53
+ x: origin.top.x + anchorHalfWidth - el.width,
54
+ y: origin.top.y - el.height,
55
+ safeX: true,
56
+ safeY: true,
57
+ safe: true,
58
+ },
59
+ topLeft: {
60
+ x: origin.top.x - anchorHalfWidth,
61
+ y: origin.top.y - el.height,
62
+ safeX: true,
63
+ safeY: true,
64
+ safe: true,
65
+ },
66
+ right: {
67
+ x: origin.right.x,
68
+ y: origin.right.y - elHalfHeigh,
69
+ safeX: true,
70
+ safeY: true,
71
+ safe: true,
72
+ },
73
+ rightTop: {
74
+ x: origin.right.x,
75
+ y: origin.right.y - anchorHalfHeight,
76
+ safeX: true,
77
+ safeY: true,
78
+ safe: true,
79
+ },
80
+ rightBottom: {
81
+ x: origin.right.x,
82
+ y: origin.right.y + anchorHalfHeight - el.height,
83
+ safeX: true,
84
+ safeY: true,
85
+ safe: true,
86
+ },
87
+ //
88
+ bottom: {
89
+ x: origin.bottom.x - elHalfWidth,
90
+ y: origin.bottom.y,
91
+ safeX: true,
92
+ safeY: true,
93
+ safe: true,
94
+ },
95
+ bottomLeft: {
96
+ x: origin.bottom.x - anchorHalfWidth,
97
+ y: origin.bottom.y,
98
+ safeX: true,
99
+ safeY: true,
100
+ safe: true,
101
+ },
102
+ bottomRight: {
103
+ x: origin.bottom.x + anchorHalfWidth - el.width,
104
+ y: origin.bottom.y,
105
+ safeX: true,
106
+ safeY: true,
107
+ safe: true,
108
+ },
109
+ //
110
+ left: {
111
+ x: origin.left.x - el.width,
112
+ y: origin.left.y - elHalfHeigh,
113
+ safeX: true,
114
+ safeY: true,
115
+ safe: true,
116
+ },
117
+ leftTop: {
118
+ x: origin.left.x - el.width,
119
+ y: origin.left.y - anchorHalfHeight,
120
+ safeX: true,
121
+ safeY: true,
122
+ safe: true,
123
+ },
124
+ leftBottom: {
125
+ x: origin.left.x - el.width,
126
+ y: origin.left.y + anchorHalfHeight - el.height,
127
+ safeX: true,
128
+ safeY: true,
129
+ safe: true,
130
+ },
131
+ };
132
+ const isSafeX = (x) => x >= boundaryRoot.x && x + el.width <= boundaryRoot.x + boundaryRoot.width;
133
+ const isSafeY = (y) => y >= boundaryRoot.y && y + el.height <= boundaryRoot.y + boundaryRoot.height;
134
+ //
135
+ Object.entries(position).forEach((entry) => {
136
+ const [plc, { x, y }] = entry;
137
+ position[plc].safeX = isSafeX(x);
138
+ position[plc].safeY = isSafeY(y);
139
+ position[plc].safe = position[plc].safeX && position[plc].safeY;
140
+ });
141
+ // now, the positions were calculated, but so far, we're too strict, so there will
142
+ // be unnecessary false negatives, which can be relaxed using "fluid" approach
143
+ // must come below safety check above
144
+ const fluid = {
145
+ x: {
146
+ top: Math.max(0, position.top.x),
147
+ topLeft: position.topLeft.x - (position.topLeft.x + el.width - boundaryRoot.width),
148
+ topRight: Math.max(0, position.topRight.x),
149
+ //
150
+ bottom: Math.max(0, position.bottom.x),
151
+ bottomLeft: position.bottomLeft.x - (position.bottomLeft.x + el.width - boundaryRoot.width),
152
+ bottomRight: Math.max(0, position.bottomRight.x),
153
+ },
154
+ y: {
155
+ // right: position.top.y - (position.top.y + el.height - boundaryRoot.height),
156
+ right: Math.max(0, position.right.y),
157
+ rightTop: Math.max(0, position.rightTop.y),
158
+ rightBottom: Math.max(0, position.rightBottom.y),
159
+ },
160
+ };
161
+ // x axis
162
+ if (el.width < boundaryRoot.width && anchor.x > 0) {
163
+ ['top', 'topLeft', 'topRight', 'bottom', 'bottomLeft', 'bottomRight'].forEach((k) => {
164
+ if (!position[k].safeX && fluid.x?.[k] !== undefined) {
165
+ position[k].x = fluid.x[k];
166
+ position[k].safeX = isSafeX(position[k].x);
167
+ position[k].safe = position[k].safeX && position[k].safeY;
168
+ }
169
+ });
170
+ }
171
+ // y
172
+ if (el.height < boundaryRoot.height && anchor.y > 0) {
173
+ ['right', 'rightTop', 'rightBottom', 'left', 'leftTop', 'leftBottom'].forEach((k) => {
174
+ if (!position[k].safeY && fluid.y?.[k] !== undefined) {
175
+ position[k].y = fluid.y[k];
176
+ position[k].safeY = isSafeY(position[k].y);
177
+ position[k].safe = position[k].safeX && position[k].safeY;
178
+ }
179
+ });
180
+ }
181
+ // todo y fluids
182
+ return { origin, position };
183
+ };
@@ -0,0 +1 @@
1
+ export declare const getId: (prefix?: string) => string;
@@ -0,0 +1,2 @@
1
+ let _id = 0;
2
+ export const getId = (prefix = 'id-') => `${prefix}${++_id}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run package && node ./scripts/date.js",
@@ -14,7 +14,8 @@
14
14
  "format": "prettier --write .",
15
15
  "prettier": "npm run format",
16
16
  "release:patch": "release -v patch",
17
- "release": "release -v minor"
17
+ "release": "release -v minor",
18
+ "test": "vitest"
18
19
  },
19
20
  "exports": {
20
21
  ".": {
@@ -38,9 +39,11 @@
38
39
  "@sveltejs/adapter-auto": "^2.0.0",
39
40
  "@sveltejs/kit": "^1.27.4",
40
41
  "@sveltejs/package": "^2.0.0",
42
+ "@types/node": "^20.11.0",
41
43
  "autoprefixer": "^10.4.16",
42
44
  "clsx": "^2.0.0",
43
45
  "esm-env": "^1.0.0",
46
+ "nodemon": "^3.0.2",
44
47
  "postcss": "^8.4.32",
45
48
  "prettier": "^3.0.0",
46
49
  "prettier-plugin-svelte": "^3.0.0",
@@ -49,15 +52,18 @@
49
52
  "svelte": "^4.2.7",
50
53
  "svelte-check": "^3.6.0",
51
54
  "tailwindcss": "^3.3.6",
55
+ "ts-node": "^10.9.2",
52
56
  "tslib": "^2.4.1",
53
57
  "typescript": "^5.0.0",
54
- "vite": "^4.4.2"
58
+ "vite": "^4.4.2",
59
+ "vitest": "^1.1.3"
55
60
  },
56
61
  "svelte": "./dist/index.js",
57
62
  "types": "./dist/index.d.ts",
58
63
  "type": "module",
59
64
  "dependencies": {
60
65
  "@marianmeres/clog": "^1.0.1",
66
+ "@marianmeres/store": "^1.5.0",
61
67
  "@marianmeres/switch-store": "^1.3.1",
62
68
  "tailwind-merge": "^2.1.0"
63
69
  }
@@ -1,3 +0,0 @@
1
- export declare const clickOutside: (node: HTMLElement, callback: () => void) => {
2
- destroy(): void;
3
- };
@@ -1,15 +0,0 @@
1
- export const clickOutside = (node, callback) => {
2
- const handleClick = (event) => {
3
- if (!event?.target)
4
- return;
5
- if (node && !node.contains(event.target) && !event.defaultPrevented) {
6
- callback();
7
- }
8
- };
9
- document.addEventListener('click', handleClick, true);
10
- return {
11
- destroy() {
12
- document.removeEventListener('click', handleClick, true);
13
- },
14
- };
15
- };