Skip to content

Commit b1cb6c7

Browse files
authored
better nav (#933)
* WIP better nav * fixes * fix * arrow key controls * rename, fix * fix
1 parent 4bafbe6 commit b1cb6c7

File tree

7 files changed

+265
-103
lines changed

7 files changed

+265
-103
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<script lang="ts">
2+
import { afterNavigate } from '$app/navigation';
3+
import { page } from '$app/stores';
4+
import { focusable_children, trap } from '@sveltejs/site-kit/actions';
5+
import { Icon } from '@sveltejs/site-kit/components';
6+
import type { Snippet } from 'svelte';
7+
8+
let { children, label }: { children: Snippet; label: string } = $props();
9+
10+
let open = $state(false);
11+
12+
afterNavigate(() => {
13+
open = false;
14+
});
15+
</script>
16+
17+
<svelte:window
18+
onkeydown={(e) => {
19+
if (e.key === 'Escape') {
20+
open = false;
21+
}
22+
}}
23+
/>
24+
25+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
26+
<details
27+
class="examples-select"
28+
bind:open
29+
ontogglecapture={(e) => {
30+
const details = e.target as HTMLDetailsElement;
31+
32+
if (details === e.currentTarget || !details.open) {
33+
return;
34+
}
35+
36+
details.scrollIntoView();
37+
}}
38+
ontoggle={(e) => {
39+
const details = e.currentTarget;
40+
if (!details.open) return;
41+
42+
// close all details elements...
43+
for (const child of details.querySelectorAll('details[open]')) {
44+
(child as HTMLDetailsElement).open = false;
45+
}
46+
47+
// except parents of the current one
48+
const current = details.querySelector(`[href="${$page.url.pathname}"]`) as HTMLAnchorElement | null;
49+
if (!current) return;
50+
51+
let node = current as Element;
52+
53+
while ((node = (node.parentNode) as Element) && node !== details) {
54+
if (node.nodeName === 'DETAILS') {
55+
(node as HTMLDetailsElement).open = true;
56+
}
57+
}
58+
59+
current.scrollIntoView();
60+
current.focus();
61+
}}
62+
onkeydown={(e) => {
63+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
64+
const children = focusable_children(e.currentTarget);
65+
66+
if (e.key === 'ArrowDown') {
67+
children.next();
68+
} else {
69+
children.prev();
70+
}
71+
}
72+
73+
if (document.activeElement?.nodeName === 'SUMMARY' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
74+
(document.activeElement.parentNode as HTMLDetailsElement).open = e.key === 'ArrowRight';
75+
}
76+
}}
77+
>
78+
<summary class="raised icon" aria-label={label}><Icon name="menu" /></summary>
79+
80+
<div class="contents" use:trap>
81+
{@render children()}
82+
</div>
83+
</details>
84+
85+
<style>
86+
details {
87+
position: relative;
88+
89+
&:has(:focus-visible) .raised.icon {
90+
outline: 2px solid var(--sk-fg-accent);
91+
border-radius: var(--sk-border-radius);
92+
}
93+
94+
span {
95+
pointer-events: none;
96+
}
97+
}
98+
99+
summary {
100+
position: absolute;
101+
display: flex;
102+
align-items: center;
103+
justify-content: center;
104+
user-select: none;
105+
}
106+
107+
details[open] summary::before {
108+
content: '';
109+
position: fixed;
110+
left: 0;
111+
top: 0;
112+
width: 100%;
113+
height: 100%;
114+
background: rgba(0, 0, 0, 0.3);
115+
backdrop-filter: grayscale(0.7) blur(3px);
116+
z-index: 9998;
117+
}
118+
119+
.contents {
120+
position: absolute;
121+
z-index: 9999;
122+
background: var(--sk-bg-2);
123+
padding: 1rem;
124+
border-radius: var(--sk-border-radius);
125+
filter: var(--sk-shadow);
126+
max-height: calc(100vh - 16rem);
127+
overflow-y: auto;
128+
}
129+
130+
.icon {
131+
position: relative;
132+
color: var(--sk-fg-3);
133+
line-height: 1;
134+
background-size: 1.8rem;
135+
z-index: 9999;
136+
}
137+
138+
.icon:hover,
139+
.icon:focus-visible {
140+
opacity: 1;
141+
}
142+
</style>

apps/svelte.dev/src/lib/components/SelectIcon.svelte

Lines changed: 0 additions & 60 deletions
This file was deleted.

apps/svelte.dev/src/routes/(authed)/playground/[id]/AppControls.svelte

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<script lang="ts">
2-
import { goto } from '$app/navigation';
2+
import { page } from '$app/stores';
33
import UserMenu from './UserMenu.svelte';
44
import { Icon } from '@sveltejs/site-kit/components';
55
import { isMac } from '$lib/utils/compat.js';
66
import { get_app_context } from '../../app-context';
77
import type { Gist, User } from '$lib/db/types';
88
import { browser } from '$app/environment';
9-
import SelectIcon from '$lib/components/SelectIcon.svelte';
9+
import ModalDropdown from '$lib/components/ModalDropdown.svelte';
1010
import { untrack } from 'svelte';
1111
import SecondaryNav from '$lib/components/SecondaryNav.svelte';
1212
import type { File } from 'editor';
@@ -39,7 +39,7 @@
3939
let saving = $state(false);
4040
let justSaved = $state(false);
4141
let justForked = $state(false);
42-
let select: ReturnType<typeof SelectIcon>;
42+
let select: ReturnType<typeof ModalDropdown>;
4343
4444
function wait(ms: number) {
4545
return new Promise((f) => setTimeout(f, ms));
@@ -180,24 +180,40 @@
180180
<svelte:window on:keydown={handleKeydown} />
181181

182182
<SecondaryNav>
183-
<SelectIcon
184-
bind:this={select}
185-
title="examples"
186-
value={gist.id}
187-
onchange={async (e) => {
188-
goto(`/playground/${e.currentTarget.value}`);
189-
}}
190-
>
191-
<option value="untitled">Create new</option>
183+
<ModalDropdown label="Examples">
184+
<div class="secondary-nav-dropdown">
185+
<a class="create-new" href="/playground/untitled">Create new</a>
186+
187+
{#each examples as section}
188+
<details>
189+
<summary>{section.title}</summary>
190+
191+
<ul>
192+
{#each section.examples as example}
193+
<li>
194+
<a
195+
href="/playground/{example.slug}"
196+
aria-current={$page.params.id === example.slug ? 'page' : undefined}
197+
>
198+
{example.title}
199+
</a>
200+
</li>
201+
{/each}
202+
</ul>
203+
</details>
204+
{/each}
205+
</div>
206+
207+
<!-- <option value="untitled">Create new</option>
192208
<option disabled selected value="">or choose an example</option>
193209
{#each examples as section}
194210
<optgroup label={section.title}>
195211
{#each section.examples as example}
196212
<option value={example.slug}>{example.title}</option>
197213
{/each}
198214
</optgroup>
199-
{/each}
200-
</SelectIcon>
215+
{/each} -->
216+
</ModalDropdown>
201217

202218
<input
203219
bind:value={name}
@@ -324,4 +340,8 @@
324340
top: -0.2rem;
325341
right: -0.2rem;
326342
}
343+
344+
.create-new {
345+
margin-bottom: 1rem;
346+
}
327347
</style>

apps/svelte.dev/src/routes/tutorial/[...slug]/Controls.svelte

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
2-
import { goto } from '$app/navigation';
2+
import { page } from '$app/stores';
33
import SecondaryNav from '$lib/components/SecondaryNav.svelte';
4-
import SelectIcon from '$lib/components/SelectIcon.svelte';
4+
import ModalDropdown from '$lib/components/ModalDropdown.svelte';
55
import type { Exercise, PartStub } from '$lib/tutorial';
66
import { Icon } from '@sveltejs/site-kit/components';
77
@@ -13,33 +13,37 @@
1313
}
1414
1515
let { index, exercise, completed, toggle }: Props = $props();
16-
17-
// TODO this really sucks, why is `exercise.slug` not the slug?
18-
let actual_slug = $derived.by(() => {
19-
const parts = exercise.slug.split('/');
20-
return `${parts[1].includes('kit') ? 'kit' : 'svelte'}/${parts[3]}`;
21-
});
2216
</script>
2317

2418
<SecondaryNav>
25-
<SelectIcon
26-
value={actual_slug}
27-
onchange={(e) => {
28-
goto(`/tutorial/${e.currentTarget.value}`);
29-
}}
30-
>
31-
{#each index as part}
32-
<optgroup label={part.title}>
33-
{#each part.chapters as chapter}
34-
<option disabled>{chapter.title}</option>
19+
<ModalDropdown label="Menu">
20+
<div class="secondary-nav-dropdown">
21+
{#each index as part}
22+
<details>
23+
<summary>{part.title}</summary>
24+
25+
{#each part.chapters as chapter}
26+
<details>
27+
<summary>{chapter.title}</summary>
3528

36-
{#each chapter.exercises as exercise}
37-
<option value={exercise.slug}>{exercise.title}</option>
29+
<ul>
30+
{#each chapter.exercises as exercise}
31+
<li value={exercise.slug}>
32+
<a
33+
aria-current={$page.url.pathname === `/tutorial/${exercise.slug}`
34+
? 'page'
35+
: undefined}
36+
href="/tutorial/{exercise.slug}">{exercise.title}</a
37+
>
38+
</li>
39+
{/each}
40+
</ul>
41+
</details>
3842
{/each}
39-
{/each}
40-
</optgroup>
41-
{/each}
42-
</SelectIcon>
43+
</details>
44+
{/each}
45+
</div>
46+
</ModalDropdown>
4347

4448
<a
4549
href={exercise.prev ? `/tutorial/${exercise.prev?.slug}` : undefined}
@@ -77,6 +81,10 @@
7781
opacity: 0.1;
7882
cursor: default;
7983
}
84+
85+
&[aria-current='page'] {
86+
color: var(--sk-fg-accent);
87+
}
8088
}
8189
8290
.breadcrumbs {

packages/site-kit/src/lib/actions/focus.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ export function forcefocus(node: HTMLInputElement) {
77

88
export function focusable_children(node: HTMLElement) {
99
const nodes: HTMLElement[] = Array.from(
10+
// this rather intimating selector selects elements that aren't children of closed <details> elements,
11+
// except for the <summary> elements that are their direct children
1012
node.querySelectorAll(
11-
'a[href], button, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])'
13+
':where(a[href], button, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])):not(details:not([open]) *), summary:not(details:not([open]) details *)'
1214
)
1315
);
1416

@@ -31,10 +33,6 @@ export function focusable_children(node: HTMLElement) {
3133
while ((node = reordered[i])) {
3234
i += d;
3335

34-
if (node.matches('details:not([open]) *')) {
35-
continue;
36-
}
37-
3836
if (!selector || node.matches(selector)) {
3937
node.focus();
4038
return;

packages/site-kit/src/lib/styles/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
@import './text.css';
1313
@import './utils/buttons.css';
1414
@import './utils/dividers.css';
15+
@import './utils/nav.css';
1516
@import './utils/twoslash.css';

0 commit comments

Comments
 (0)