Skip to content
This repository was archived by the owner on Aug 5, 2025. It is now read-only.

nicer action exercise #326

Merged
merged 2 commits into from
Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@

<div>
<button
aria-current="{selected === 'red' ? 'true' : undefined}"
aria-current={selected === 'red'}
aria-label="red"
style="background: red"
on:click={() => selected = 'red'}
></button>

<button
aria-current="{selected === 'orange' ? 'true' : undefined}"
aria-current={selected === 'orange'}
aria-label="orange"
style="background: orange"
on:click={() => selected = 'orange'}
></button>

<button
aria-current="{selected === 'yellow' ? 'true' : undefined}"
aria-current={selected === 'yellow'}
aria-label="yellow"
style="background: yellow"
on:click={() => selected = 'yellow'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<div>
{#each colors as color, i}
<button
aria-current="{selected === color ? 'true' : undefined}"
aria-current={selected === color}
aria-label={color}
style="background: {color}"
on:click={() => selected = color}
Expand Down
63 changes: 34 additions & 29 deletions content/tutorial/03-advanced-svelte/04-actions/01-actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,51 +9,56 @@ Actions are essentially element-level lifecycle functions. They're useful for th
- tooltips
- adding custom event handlers

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...
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.

We can fix that with an action. Import `trapFocus` from `actions.js`...

```svelte
/// file: App.svelte
<script>
+++import { clickOutside } from './actions.js';+++
import Canvas from './Canvas.svelte';
+++import { trapFocus } from './actions.js';+++

const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'white', 'black'];
let selected = colors[0];
let size = 10;

let showModal = true;
let showMenu = true;
</script>
```

...then use it with the element:
...then add it to the menu with the `use:` directive:

```svelte
/// file: App.svelte
<div
class="box"
+++use:clickOutside+++
on:outclick={() => (showModal = false)}
>
Click outside me!
</div>
<div class="menu" +++use:trapFocus+++>
```

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.
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.

First, we need to add an event listener that intercepts Tab key presses:

```js
/// file: actions.js
focusable()[0]?.focus();

+++node.addEventListener('keydown', handleKeydown);+++
```

We want to fire the `outclick` event when the user clicks outside the orange box. One possible implementation looks like this:
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:

```js
/// file: actions.js
export function clickOutside(node) {
const handleClick = (event) => {
if (!node.contains(event.target)) {
node.dispatchEvent(new CustomEvent('outclick'));
}
};

document.addEventListener('click', handleClick, true);

return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
focusable()[0]?.focus();

node.addEventListener('keydown', handleKeydown);

+++return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
previous?.focus();
}
};+++
```

Update the `clickOutside` function, click the button to show the modal and then click outside it to close it.
Now, when you open the menu, you can cycle through the options with the Tab key.
Original file line number Diff line number Diff line change
@@ -1,33 +1,124 @@
<script>
let showModal = true;
import Canvas from './Canvas.svelte';

const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'white', 'black'];
let selected = colors[0];
let size = 10;

let showMenu = true;
</script>

<button on:click={() => (showModal = true)}>
Show Modal
</button>
<div class="container">
<Canvas color={selected} size={size} />

{#if showMenu}
<div
class="modal-background"
on:click|self={() => showMenu = false}
on:keydown={(e) => {
if (e.key === 'Escape') showMenu = false;
}}
>
<div class="menu">
<div class="colors">
{#each colors as color}
<button
class="color"
aria-label={color}
aria-current={selected === color}
style="--color: {color}"
on:click={() => {
selected = color;
}}
/>
{/each}
</div>

{#if showModal}
<div
class="box"
on:outclick={() => (showModal = false)}
>
Click outside me!
</div>
{/if}
<label>
small
<input type="range" bind:value={size} min="1" max="50" />
large
</label>
</div>
</div>
{/if}

<button class="show-menu" on:click={() => showMenu = !showMenu}>
{showMenu ? 'close' : 'menu'}
</button>
</div>

<style>
.box {
--width: 100px;
--height: 100px;
.container {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
}

.show-menu {
position: absolute;
width: var(--width);
height: var(--height);
left: calc(50% - var(--width) / 2);
top: calc(50% - var(--height) / 2);
border-radius: 4px;
background-color: #ff3e00;
color: #fff;
text-align: center;
font-weight: bold;
}
</style>
left: 1em;
top: 1em;
width: 5em;
}

.modal-background {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
left: 0;
top: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(20px);
}

.menu {
position: relative;
background: var(--bg-2);
width: calc(100% - 2em);
max-width: 28em;
padding: 1em 1em 0.5em 1em;
border-radius: 1em;
box-sizing: border-box;
user-select: none;
}

.colors {
display: grid;
align-items: center;
grid-template-columns: repeat(9, 1fr);
grid-gap: 0.5em;
}

.color {
aspect-ratio: 1;
border-radius: 50%;
background: var(--color, #fff);
transform: none;
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.2));
transition: all 0.1s;
}

.color[aria-current="true"] {
transform: translate(1px, 1px);
filter: none;
box-shadow: inset 3px 3px 4px rgba(0,0,0,0.2);
}

.menu label {
display: flex;
width: 100%;
margin: 1em 0 0 0;
}

.menu input {
flex: 1;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<script>
import { onMount } from "svelte";

export let color;
export let size;

let canvas;
let context;
let previous;

function get_coords(e) {
const { clientX, clientY } = e;
const { left, top } = canvas.getBoundingClientRect();
const x = clientX - left;
const y = clientY - top;
return { x, y };
}

onMount(() => {
context = canvas.getContext('2d');

function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}

window.addEventListener('resize', resize);
resize();

return () => {
window.removeEventListener('resize', resize);
};
});
</script>



<canvas
bind:this={canvas}
on:pointerdown={(e) => {
const coords = get_coords(e);
context.fillStyle = color;
context.beginPath();
context.arc(coords.x, coords.y, size / 2, 0, 2 * Math.PI);
context.fill();

previous = coords;
}}
on:pointerleave={() => {
previous = null;
}}
on:pointermove={(e) => {
const coords = get_coords(e);

if (e.buttons === 1) {
e.preventDefault();

context.strokeStyle = color;
context.lineWidth = size;
context.lineCap = 'round';
context.beginPath();
context.moveTo(previous.x, previous.y);
context.lineTo(coords.x, coords.y);
context.stroke();
}

previous = coords;
}}
/>

{#if previous}
<div
class="preview"
style="--color: {color}; --size: {size}px; --x: {previous.x}px; --y: {previous.y}px"
/>
{/if}

<style>
canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}

.preview {
position: absolute;
left: var(--x);
top: var(--y);
width: var(--size);
height: var(--size);
transform: translate(-50%, -50%);
background: var(--color);
border-radius: 50%;
opacity: 0.5;
pointer-events: none;
}
</style>
Loading