Skip to content

Commit a4207a5

Browse files
committed
Add input and focus attachments (autoFocus, selectOnFocus, debounceEvent, ...)
1 parent 9828c21 commit a4207a5

8 files changed

Lines changed: 234 additions & 6 deletions

File tree

packages/svelte-attachments/src/lib/dataBackground.svelte.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { untrack } from 'svelte';
2+
import { Attachment } from 'svelte/attachments';
23
import { Tween } from 'svelte/motion';
34
import { EasingFunction } from 'svelte/transition';
45
import { scaleLinear } from 'd3-scale';
56

6-
// Define Attachment type locally until Svelte 5.29+ is available
7-
export type Attachment<T extends EventTarget = Element> = (element: T) => void | (() => void);
8-
97
export type DataBackgroundOptions = {
108
value: number | null | undefined;
119
domain?: [number, number];
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Attachment } from 'svelte/attachments';
2+
import { delay } from '@layerstack/utils';
3+
4+
export function focusMove(
5+
options: { restoreFocus?: boolean; delay?: number; disabled?: boolean } = {
6+
restoreFocus: false,
7+
delay: 0,
8+
disabled: false,
9+
}
10+
): Attachment<HTMLElement | SVGElement> {
11+
return (node: HTMLElement | SVGElement) => {
12+
let previousActiveElement: Element | null = null;
13+
14+
if (!options.disabled) {
15+
previousActiveElement = document.activeElement;
16+
17+
// Set `tabIndex` to `-1` which makes any element (ex. div) focusable programmaitcally (and mouse), but not via keyboard navigation - https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
18+
node.tabIndex = -1;
19+
20+
// Appear to need to wait for tabIndex to update before applying focus
21+
delay(options.delay ?? 0).then(() => {
22+
node.focus();
23+
});
24+
}
25+
26+
return () => {
27+
// Restore previous active element
28+
if (
29+
!options.disabled &&
30+
options.restoreFocus &&
31+
previousActiveElement instanceof HTMLElement
32+
) {
33+
previousActiveElement.focus();
34+
}
35+
};
36+
};
37+
}
38+
39+
// TODO: Add `focusTrap`
40+
// https://css-tricks.com/a-css-approach-to-trap-focus-inside-of-an-element/
41+
// export function focusTrap(): Attachment<HTMLElement> {
42+
// return (node: HTMLElement) => {
43+
// // TODO: Implementation
44+
// return () => {
45+
// // cleanup
46+
// };
47+
// };
48+
// }

packages/svelte-attachments/src/lib/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './dataBackground.svelte.js';
2-
// export * from './input.js';
2+
export * from './focus.js';
3+
export * from './input.js';
34
// export * from './layout.js';
45
// export * from './mouse.js';
56
// export * from './multi.js';
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Attachment } from 'svelte/attachments';
2+
import { focusMove } from './focus.js';
3+
4+
/**
5+
* Auto focus node when rendered. Useful for inputs
6+
*/
7+
export function autoFocus(
8+
options?: Parameters<typeof focusMove>['0']
9+
): Attachment<HTMLElement | SVGElement> {
10+
// Delay by 5ms by default since Dialog/Drawer/Menu also call `focusMove` but with default `0ms` delay, and we want to focus last
11+
// Chrome works with `1ms`, but Firefox required `2ms` and Safari required `3ms`, so using `5ms` as a buffer
12+
return focusMove({ delay: 5, ...options });
13+
}
14+
15+
/**
16+
* Selects the text inside a text node when the node is focused
17+
*/
18+
export function selectOnFocus(): Attachment<HTMLInputElement | HTMLTextAreaElement> {
19+
return (node: HTMLInputElement | HTMLTextAreaElement) => {
20+
const handleFocus = (event: Event) => {
21+
node.select();
22+
};
23+
24+
node.addEventListener('focus', handleFocus);
25+
26+
return () => {
27+
node.removeEventListener('focus', handleFocus);
28+
};
29+
};
30+
}
31+
32+
/**
33+
* Blurs the node when Escape is pressed
34+
*/
35+
export function blurOnEscape(): Attachment<HTMLInputElement | HTMLTextAreaElement> {
36+
return (node: HTMLInputElement | HTMLTextAreaElement) => {
37+
const handleKey = (event: Event) => {
38+
if (event instanceof KeyboardEvent && event.key === 'Escape') {
39+
node.blur();
40+
}
41+
};
42+
43+
node.addEventListener('keydown', handleKey);
44+
45+
return () => {
46+
node.removeEventListener('keydown', handleKey);
47+
};
48+
};
49+
}
50+
51+
/**
52+
* Automatically resize textarea based on content
53+
* See:
54+
* - https://svelte.dev/repl/ead0f1fcd2d4402bbbd64eca1d665341?version=3.14.1
55+
* - https://svelte.dev/repl/f1a7e24a08a54947bb4447f295c741fb?version=3.14.1
56+
*/
57+
export function autoHeight(): Attachment<HTMLTextAreaElement> {
58+
return (node: HTMLTextAreaElement) => {
59+
function resize({ target }: { target: EventTarget | null }) {
60+
if (target instanceof HTMLElement) {
61+
target.style.height = '1px';
62+
target.style.height = +target.scrollHeight + 'px';
63+
}
64+
}
65+
66+
node.style.overflow = 'hidden';
67+
node.addEventListener('input', resize);
68+
69+
// Resize initially
70+
resize({ target: node });
71+
72+
return () => {
73+
node.removeEventListener('input', resize);
74+
};
75+
};
76+
}
77+
78+
/**
79+
* Debounce event handler (change, input, etc)
80+
*/
81+
export function debounceEvent(
82+
options?: { type: string; listener: (e: Event) => any; timeout?: number } | null
83+
): Attachment<HTMLInputElement | HTMLTextAreaElement> {
84+
return (node: HTMLInputElement | HTMLTextAreaElement) => {
85+
let lastTimeoutId: ReturnType<typeof setTimeout>;
86+
87+
if (options) {
88+
const { type, listener, timeout } = options;
89+
90+
function onEvent(e: Event) {
91+
clearTimeout(lastTimeoutId);
92+
lastTimeoutId = setTimeout(() => {
93+
listener(e);
94+
}, timeout ?? 300);
95+
}
96+
97+
node.addEventListener(type, onEvent);
98+
99+
return () => {
100+
node.removeEventListener(type, onEvent);
101+
clearTimeout(lastTimeoutId);
102+
};
103+
}
104+
105+
return () => {
106+
clearTimeout(lastTimeoutId);
107+
};
108+
};
109+
}

sites/docs/src/routes/_NavMenu.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
2222
const attachments = [
2323
'dataBackground',
24-
// 'input',
24+
'input',
2525
// 'layout',
2626
// 'mouse',
2727
// 'multi',

sites/docs/src/routes/docs/svelte-attachments/dataBackground/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
<h1>Usage</h1>
4646

4747
<Code
48-
source={`import { dataBackground } from '@layerstack/svelte-actions';`}
48+
source={`import { dataBackground } from '@layerstack/svelte-attachments';`}
4949
language="javascript"
5050
class="mb-4"
5151
/>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script lang="ts">
2+
import {
3+
autoFocus,
4+
autoHeight,
5+
blurOnEscape,
6+
selectOnFocus,
7+
debounceEvent,
8+
} from '@layerstack/svelte-attachments';
9+
10+
import Preview from '$docs/Preview.svelte';
11+
import Code from '$docs/Code.svelte';
12+
</script>
13+
14+
<h1>Usage</h1>
15+
16+
<Code
17+
source={`import { autoFocus, autoHeight, blurOnEscape, selectOnFocus, debounceEvent } from '@layerstack/svelte-attachments';`}
18+
language="javascript"
19+
/>
20+
21+
<h2>autoFocus <small>Auto focus node when rendered</small></h2>
22+
23+
<Preview>
24+
<input value="Example text" {@attach autoFocus()} class="border" />
25+
</Preview>
26+
27+
<h2>selectOnFocus <small>Selects the text inside a text node when the node is focused</small></h2>
28+
29+
<Preview>
30+
<input value="Example text" {@attach selectOnFocus()} class="border" />
31+
</Preview>
32+
33+
<h2>blurOnEscape <small>Blurs the node when Escape is pressed</small></h2>
34+
35+
<Preview>
36+
<input value="Example text" {@attach blurOnEscape()} class="border" />
37+
</Preview>
38+
39+
<h2>autoHeight <small>Automatically resize textarea based on content</small></h2>
40+
41+
<Preview>
42+
<textarea value="Example text" {@attach autoHeight()} class="border"></textarea>
43+
</Preview>
44+
45+
<h2>debounceEvent <small>Debounce any event (input, change, etc)</small></h2>
46+
47+
<Preview>
48+
<input
49+
value="Example text"
50+
{@attach debounceEvent({
51+
type: 'input',
52+
listener: (e) => {
53+
// @ts-expect-error
54+
console.log(e.target.value);
55+
},
56+
timeout: 1000,
57+
})}
58+
class="border"
59+
/>
60+
</Preview>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import source from '$svelte-attachments/input.ts?raw';
2+
import pageSource from './+page.svelte?raw';
3+
4+
export async function load() {
5+
return {
6+
meta: {
7+
source,
8+
pageSource,
9+
related: ['components/TextField', 'components/Input'],
10+
},
11+
};
12+
}

0 commit comments

Comments
 (0)