Skip to content

Commit b0e2f6e

Browse files
Rich-HarrisRich Harris
and
Rich Harris
authored
nicer action exercise (sveltejs#326)
* nicer action exercise * different focus heuristic --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 2c4e0df commit b0e2f6e

File tree

9 files changed

+462
-124
lines changed

9 files changed

+462
-124
lines changed

content/tutorial/01-svelte/04-logic/04-each-blocks/app-a/src/lib/App.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,21 @@
77

88
<div>
99
<button
10-
aria-current="{selected === 'red' ? 'true' : undefined}"
10+
aria-current={selected === 'red'}
1111
aria-label="red"
1212
style="background: red"
1313
on:click={() => selected = 'red'}
1414
></button>
1515

1616
<button
17-
aria-current="{selected === 'orange' ? 'true' : undefined}"
17+
aria-current={selected === 'orange'}
1818
aria-label="orange"
1919
style="background: orange"
2020
on:click={() => selected = 'orange'}
2121
></button>
2222

2323
<button
24-
aria-current="{selected === 'yellow' ? 'true' : undefined}"
24+
aria-current={selected === 'yellow'}
2525
aria-label="yellow"
2626
style="background: yellow"
2727
on:click={() => selected = 'yellow'}

content/tutorial/01-svelte/04-logic/04-each-blocks/app-b/src/lib/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<div>
99
{#each colors as color, i}
1010
<button
11-
aria-current="{selected === color ? 'true' : undefined}"
11+
aria-current={selected === color}
1212
aria-label={color}
1313
style="background: {color}"
1414
on:click={() => selected = color}

content/tutorial/03-advanced-svelte/04-actions/01-actions/README.md

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,51 +9,56 @@ Actions are essentially element-level lifecycle functions. They're useful for th
99
- tooltips
1010
- adding custom event handlers
1111

12-
In this app, we want to make the orange modal close when the user clicks outside it. It has an event handler for the `outclick` event, but it isn't a native DOM event. We have to dispatch it ourselves. First, import the `clickOutside` function...
12+
In this app, you can scribble on the `<canvas>`, and change colours and brush size via the menu. But if you open the menu and cycle through the options with the Tab key, you'll soon find that the focus isn't _trapped_ inside the modal.
13+
14+
We can fix that with an action. Import `trapFocus` from `actions.js`...
1315

1416
```svelte
1517
/// file: App.svelte
1618
<script>
17-
+++import { clickOutside } from './actions.js';+++
19+
import Canvas from './Canvas.svelte';
20+
+++import { trapFocus } from './actions.js';+++
21+
22+
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'white', 'black'];
23+
let selected = colors[0];
24+
let size = 10;
1825
19-
let showModal = true;
26+
let showMenu = true;
2027
</script>
2128
```
2229

23-
...then use it with the element:
30+
...then add it to the menu with the `use:` directive:
2431

2532
```svelte
2633
/// file: App.svelte
27-
<div
28-
class="box"
29-
+++use:clickOutside+++
30-
on:outclick={() => (showModal = false)}
31-
>
32-
Click outside me!
33-
</div>
34+
<div class="menu" +++use:trapFocus+++>
3435
```
3536

36-
Open `actions.js`. Like transition functions, an action function receives a `node` (which is the element that the action is applied to) and some optional parameters, and returns an action object. That object can have a `destroy` function, which is called when the element is unmounted.
37+
Let's take a look at the `trapFocus` function in `actions.js`. An action function is called with a `node` — the `<div class="menu">` in our case — when the node is mounted to the DOM, and can return an action object with a `destroy` method.
38+
39+
First, we need to add an event listener that intercepts Tab key presses:
40+
41+
```js
42+
/// file: actions.js
43+
focusable()[0]?.focus();
44+
45+
+++node.addEventListener('keydown', handleKeydown);+++
46+
```
3747
38-
We want to fire the `outclick` event when the user clicks outside the orange box. One possible implementation looks like this:
48+
Second, we need to do some cleanup when the node is unmounted — removing the event listener, and restoring focus to where it was before the element mounted:
3949
4050
```js
4151
/// file: actions.js
42-
export function clickOutside(node) {
43-
const handleClick = (event) => {
44-
if (!node.contains(event.target)) {
45-
node.dispatchEvent(new CustomEvent('outclick'));
46-
}
47-
};
48-
49-
document.addEventListener('click', handleClick, true);
50-
51-
return {
52-
destroy() {
53-
document.removeEventListener('click', handleClick, true);
54-
}
55-
};
56-
}
52+
focusable()[0]?.focus();
53+
54+
node.addEventListener('keydown', handleKeydown);
55+
56+
+++return {
57+
destroy() {
58+
node.removeEventListener('keydown', handleKeydown);
59+
previous?.focus();
60+
}
61+
};+++
5762
```
5863
59-
Update the `clickOutside` function, click the button to show the modal and then click outside it to close it.
64+
Now, when you open the menu, you can cycle through the options with the Tab key.
Lines changed: 117 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,124 @@
11
<script>
2-
let showModal = true;
2+
import Canvas from './Canvas.svelte';
3+
4+
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'white', 'black'];
5+
let selected = colors[0];
6+
let size = 10;
7+
8+
let showMenu = true;
39
</script>
410

5-
<button on:click={() => (showModal = true)}>
6-
Show Modal
7-
</button>
11+
<div class="container">
12+
<Canvas color={selected} size={size} />
13+
14+
{#if showMenu}
15+
<div
16+
class="modal-background"
17+
on:click|self={() => showMenu = false}
18+
on:keydown={(e) => {
19+
if (e.key === 'Escape') showMenu = false;
20+
}}
21+
>
22+
<div class="menu">
23+
<div class="colors">
24+
{#each colors as color}
25+
<button
26+
class="color"
27+
aria-label={color}
28+
aria-current={selected === color}
29+
style="--color: {color}"
30+
on:click={() => {
31+
selected = color;
32+
}}
33+
/>
34+
{/each}
35+
</div>
836

9-
{#if showModal}
10-
<div
11-
class="box"
12-
on:outclick={() => (showModal = false)}
13-
>
14-
Click outside me!
15-
</div>
16-
{/if}
37+
<label>
38+
small
39+
<input type="range" bind:value={size} min="1" max="50" />
40+
large
41+
</label>
42+
</div>
43+
</div>
44+
{/if}
45+
46+
<button class="show-menu" on:click={() => showMenu = !showMenu}>
47+
{showMenu ? 'close' : 'menu'}
48+
</button>
49+
</div>
1750

1851
<style>
19-
.box {
20-
--width: 100px;
21-
--height: 100px;
52+
.container {
53+
display: flex;
54+
justify-content: center;
55+
align-items: center;
56+
position: fixed;
57+
left: 0;
58+
top: 0;
59+
width: 100%;
60+
height: 100%;
61+
}
62+
63+
.show-menu {
2264
position: absolute;
23-
width: var(--width);
24-
height: var(--height);
25-
left: calc(50% - var(--width) / 2);
26-
top: calc(50% - var(--height) / 2);
27-
border-radius: 4px;
28-
background-color: #ff3e00;
29-
color: #fff;
30-
text-align: center;
31-
font-weight: bold;
32-
}
33-
</style>
65+
left: 1em;
66+
top: 1em;
67+
width: 5em;
68+
}
69+
70+
.modal-background {
71+
position: fixed;
72+
display: flex;
73+
justify-content: center;
74+
align-items: center;
75+
left: 0;
76+
top: 0;
77+
width: 100%;
78+
height: 100%;
79+
backdrop-filter: blur(20px);
80+
}
81+
82+
.menu {
83+
position: relative;
84+
background: var(--bg-2);
85+
width: calc(100% - 2em);
86+
max-width: 28em;
87+
padding: 1em 1em 0.5em 1em;
88+
border-radius: 1em;
89+
box-sizing: border-box;
90+
user-select: none;
91+
}
92+
93+
.colors {
94+
display: grid;
95+
align-items: center;
96+
grid-template-columns: repeat(9, 1fr);
97+
grid-gap: 0.5em;
98+
}
99+
100+
.color {
101+
aspect-ratio: 1;
102+
border-radius: 50%;
103+
background: var(--color, #fff);
104+
transform: none;
105+
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.2));
106+
transition: all 0.1s;
107+
}
108+
109+
.color[aria-current="true"] {
110+
transform: translate(1px, 1px);
111+
filter: none;
112+
box-shadow: inset 3px 3px 4px rgba(0,0,0,0.2);
113+
}
114+
115+
.menu label {
116+
display: flex;
117+
width: 100%;
118+
margin: 1em 0 0 0;
119+
}
120+
121+
.menu input {
122+
flex: 1;
123+
}
124+
</style>
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<script>
2+
import { onMount } from "svelte";
3+
4+
export let color;
5+
export let size;
6+
7+
let canvas;
8+
let context;
9+
let previous;
10+
11+
function get_coords(e) {
12+
const { clientX, clientY } = e;
13+
const { left, top } = canvas.getBoundingClientRect();
14+
const x = clientX - left;
15+
const y = clientY - top;
16+
return { x, y };
17+
}
18+
19+
onMount(() => {
20+
context = canvas.getContext('2d');
21+
22+
function resize() {
23+
canvas.width = window.innerWidth;
24+
canvas.height = window.innerHeight;
25+
}
26+
27+
window.addEventListener('resize', resize);
28+
resize();
29+
30+
return () => {
31+
window.removeEventListener('resize', resize);
32+
};
33+
});
34+
</script>
35+
36+
37+
38+
<canvas
39+
bind:this={canvas}
40+
on:pointerdown={(e) => {
41+
const coords = get_coords(e);
42+
context.fillStyle = color;
43+
context.beginPath();
44+
context.arc(coords.x, coords.y, size / 2, 0, 2 * Math.PI);
45+
context.fill();
46+
47+
previous = coords;
48+
}}
49+
on:pointerleave={() => {
50+
previous = null;
51+
}}
52+
on:pointermove={(e) => {
53+
const coords = get_coords(e);
54+
55+
if (e.buttons === 1) {
56+
e.preventDefault();
57+
58+
context.strokeStyle = color;
59+
context.lineWidth = size;
60+
context.lineCap = 'round';
61+
context.beginPath();
62+
context.moveTo(previous.x, previous.y);
63+
context.lineTo(coords.x, coords.y);
64+
context.stroke();
65+
}
66+
67+
previous = coords;
68+
}}
69+
/>
70+
71+
{#if previous}
72+
<div
73+
class="preview"
74+
style="--color: {color}; --size: {size}px; --x: {previous.x}px; --y: {previous.y}px"
75+
/>
76+
{/if}
77+
78+
<style>
79+
canvas {
80+
position: absolute;
81+
left: 0;
82+
top: 0;
83+
width: 100%;
84+
height: 100%;
85+
}
86+
87+
.preview {
88+
position: absolute;
89+
left: var(--x);
90+
top: var(--y);
91+
width: var(--size);
92+
height: var(--size);
93+
transform: translate(-50%, -50%);
94+
background: var(--color);
95+
border-radius: 50%;
96+
opacity: 0.5;
97+
pointer-events: none;
98+
}
99+
</style>

0 commit comments

Comments
 (0)