Skip to content

Commit 30bad4e

Browse files
committed
WIP better nav
1 parent fac2647 commit 30bad4e

File tree

6 files changed

+190
-39
lines changed

6 files changed

+190
-39
lines changed

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

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,66 @@
11
<script lang="ts">
2+
import { afterNavigate } from '$app/navigation';
3+
import { page } from '$app/stores';
4+
import { trap } from '@sveltejs/site-kit/actions';
25
import { Icon } from '@sveltejs/site-kit/components';
36
import type { Snippet } from 'svelte';
4-
import type { HTMLSelectAttributes } from 'svelte/elements';
57
6-
let { children, value, ...props }: HTMLSelectAttributes & { children: Snippet } = $props();
8+
let { children }: { children: Snippet } = $props();
79
8-
export { value }; // allow it to be temporarily overwritten
10+
let open = $state(false);
11+
12+
afterNavigate(() => {
13+
open = false;
14+
});
915
</script>
1016

11-
<div class="examples-select">
12-
<span class="raised icon"><Icon name="menu" /></span>
13-
<select {...props} {value}>
17+
<svelte:window
18+
onkeydown={(e) => {
19+
if (e.key === 'Escape') {
20+
open = false;
21+
}
22+
}}
23+
/>
24+
25+
<details
26+
class="examples-select"
27+
bind:open
28+
ontoggle={(e) => {
29+
const details = e.currentTarget;
30+
if (!details.open) return;
31+
32+
// close all details elements...
33+
for (const child of details.querySelectorAll('details[open]')) {
34+
(child as HTMLDetailsElement).open = false;
35+
}
36+
37+
// except parents of the current one
38+
const current = details.querySelector(`[href="${$page.url.pathname}"]`);
39+
40+
let node = current as Element;
41+
42+
while ((node = (node.parentNode) as Element) && node !== details) {
43+
if (node.nodeName === 'DETAILS') {
44+
(node as HTMLDetailsElement).open = true;
45+
}
46+
}
47+
}}
48+
>
49+
<summary class="raised icon"><Icon name="menu" /></summary>
50+
51+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions (handled by <svelte:window>) -->
52+
<div class="modal-background" onclick={() => (open = false)}></div>
53+
54+
<div class="contents" use:trap>
1455
{@render children()}
15-
</select>
16-
</div>
56+
</div>
57+
</details>
1758

1859
<style>
1960
.examples-select {
2061
position: relative;
2162
22-
&:has(select:focus-visible) .raised.icon {
63+
&:has(:focus-visible) .raised.icon {
2364
outline: 2px solid var(--sk-fg-accent);
2465
border-radius: var(--sk-border-radius);
2566
}
@@ -38,13 +79,35 @@
3879
height: 100%;
3980
}
4081
41-
span.icon {
82+
summary {
4283
display: flex;
4384
align-items: center;
4485
justify-content: center;
4586
user-select: none;
4687
}
4788
89+
.modal-background {
90+
position: fixed;
91+
left: 0;
92+
top: 0;
93+
width: 100%;
94+
height: 100%;
95+
background: rgba(0, 0, 0, 0.3);
96+
backdrop-filter: grayscale(0.7) blur(3px);
97+
z-index: 9998;
98+
}
99+
100+
.contents {
101+
position: absolute;
102+
z-index: 9999;
103+
background: white;
104+
padding: 1rem;
105+
border-radius: var(--sk-border-radius);
106+
filter: var(--sk-shadow);
107+
max-height: calc(100vh - 16rem);
108+
overflow-y: auto;
109+
}
110+
48111
.icon {
49112
position: relative;
50113
color: var(--sk-fg-3);

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import { page } from '$app/stores';
23
import { goto } from '$app/navigation';
34
import UserMenu from './UserMenu.svelte';
45
import { Icon } from '@sveltejs/site-kit/components';
@@ -188,15 +189,38 @@
188189
goto(`/playground/${e.currentTarget.value}`);
189190
}}
190191
>
191-
<option value="untitled">Create new</option>
192+
<div class="secondary-nav-dropdown">
193+
<a class="create-new" href="/playground/untitled">Create new</a>
194+
195+
{#each examples as section}
196+
<details>
197+
<summary>{section.title}</summary>
198+
199+
<ul>
200+
{#each section.examples as example}
201+
<li>
202+
<a
203+
href="/playground/{example.slug}"
204+
aria-current={$page.params.id === example.slug ? 'page' : undefined}
205+
>
206+
{example.title}
207+
</a>
208+
</li>
209+
{/each}
210+
</ul>
211+
</details>
212+
{/each}
213+
</div>
214+
215+
<!-- <option value="untitled">Create new</option>
192216
<option disabled selected value="">or choose an example</option>
193217
{#each examples as section}
194218
<optgroup label={section.title}>
195219
{#each section.examples as example}
196220
<option value={example.slug}>{example.title}</option>
197221
{/each}
198222
</optgroup>
199-
{/each}
223+
{/each} -->
200224
</SelectIcon>
201225

202226
<input
@@ -324,4 +348,8 @@
324348
top: -0.2rem;
325349
right: -0.2rem;
326350
}
351+
352+
.create-new {
353+
margin-bottom: 1rem;
354+
}
327355
</style>

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

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { goto } from '$app/navigation';
2+
import { page } from '$app/stores';
33
import SecondaryNav from '$lib/components/SecondaryNav.svelte';
44
import SelectIcon from '$lib/components/SelectIcon.svelte';
55
import type { Exercise, PartStub } from '$lib/tutorial';
@@ -13,32 +13,36 @@
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+
<SelectIcon>
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}
43+
</details>
44+
{/each}
45+
</div>
4246
</SelectIcon>
4347

4448
<a
@@ -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: 4 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;
@@ -60,6 +58,7 @@ export function trap(node: HTMLElement, { reset_focus = true }: { reset_focus?:
6058
if (e.shiftKey) {
6159
group.prev();
6260
} else {
61+
console.log('next', group);
6362
group.next();
6463
}
6564
}

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';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
.secondary-nav-dropdown {
2+
max-height: 50rem;
3+
width: 30rem;
4+
font: var(--sk-font-ui-medium);
5+
6+
details {
7+
padding-left: 1rem;
8+
9+
summary {
10+
position: relative;
11+
font: inherit;
12+
display: block;
13+
user-select: none;
14+
15+
&::before {
16+
content: '';
17+
position: absolute;
18+
top: 0.3rem;
19+
left: -2rem;
20+
width: 1.8rem;
21+
height: 1.8rem;
22+
background: url($lib/icons/chevron.svg) no-repeat 50% 50%;
23+
background-size: 100%;
24+
rotate: -90deg;
25+
}
26+
27+
[open] > &::before {
28+
rotate: none;
29+
}
30+
}
31+
32+
ul {
33+
font: inherit;
34+
list-style: none;
35+
margin: 0;
36+
padding-left: 1rem;
37+
}
38+
}
39+
40+
& > details {
41+
padding-left: 2rem;
42+
}
43+
44+
a:not([aria-current='page']) {
45+
color: inherit;
46+
}
47+
48+
/* necessary for reasons i don't fully understand */
49+
& > details[open]:last-child:not(:has([aria-current='page'])) {
50+
padding-bottom: 1rem;
51+
}
52+
}

0 commit comments

Comments
 (0)