Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
/.cache
/vendor
/node_modules
/.pnpm-store
/web/static
/web/cpresources
/web/assets
Expand Down
4 changes: 2 additions & 2 deletions .zed/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
"JavaScript": {
"format_on_save": "on",
"prettier": { "allowed": false },
"language_servers": ["oxlint", "oxfmt", "tsgo"],
"language_servers": ["oxlint", "oxfmt", "typescript-ls"],
"formatter": [{ "language_server": { "name": "oxfmt" } }],
"code_actions_on_format": { "source.fixAll.oxc": true }
},
"TypeScript": {
"format_on_save": "on",
"prettier": { "allowed": false },
"language_servers": ["oxlint", "oxfmt", "tsgo"],
"language_servers": ["oxlint", "oxfmt", "typescript-ls"],
"formatter": [{ "language_server": { "name": "oxfmt" } }],
"code_actions_on_format": { "source.fixAll.oxc": true }
},
Expand Down
2 changes: 1 addition & 1 deletion config/vite.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

return [
'manifestPath' => $manifest,
'useDevServer' => !is_file($manifest),
'useDevServer' => App::env('CRAFT_ENVIRONMENT') === 'dev' && !is_file($manifest),
'serverPublic' => UrlHelper::siteHost() . $viteBase . '/',
'devServerPublic' => implode(':', [
$primarySiteUrl,
Expand Down
2 changes: 1 addition & 1 deletion modules/general/web/twig/GeneralExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ public static function onlyEnv(?string $markup, string|array $environments): str
return $markup;
}

return sprintf('<template> %s </template>', $markup);
return '';
}

public static function plain(mixed $string): string
Expand Down
1 change: 1 addition & 0 deletions oxfmt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const config: OxfmtConfig = {
semi: false,
singleQuote: true,
arrowParens: 'avoid',
bracketSameLine: true,
sortTailwindcss: true,
sortPackageJson: true,
quoteProps: 'consistent',
Expand Down
3 changes: 1 addition & 2 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,7 @@ All `data-*` attributes on the target element are automatically converted to com
data-uid="123e4567-e89b-12d3-a456-426614174000"
data-play-inline="true"
data-config='{"autoplay": false, "controls": true}'
data-delay="1500"
>
data-delay="1500">
</x-svelte>
```

Expand Down
3 changes: 1 addition & 2 deletions src/lib/components/common/Image.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@
{height}
{...rest}
{style}
src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
/>
src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" />
{/snippet}
</svelte:boundary>
{/if}
71 changes: 51 additions & 20 deletions src/lib/components/common/Modal.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script module lang="ts">
const FOCUSABLE = 'a,button,input,select,textarea,[tabindex="0"]'
const FOCUSABLE =
'a[href],button,input,select,textarea,iframe,audio[controls],video[controls],[contenteditable]:not([contenteditable="false"]),[tabindex]:not([tabindex="-1"])'

let active = $state<string | null>(null)

Expand All @@ -16,13 +17,13 @@
import type { Snippet } from 'svelte'
import { blur } from 'svelte/transition'
import Icon from '$lib/components/common/Icon.svelte'
import { FocusableSchema } from '$lib/schemas/app'
import { lockScroll } from '$lib/util/scroll-lock'

type ModalPosition = 'top-left' | 'top' | 'top-right' | 'right' | 'bottom-right' | 'bottom' | 'bottom-left' | 'left'

interface ModalProps {
id: string
label?: string
position?: ModalPosition | null
overlay?: 'polite' | 'assertive'
container?: `max-w-${string}`
Expand All @@ -32,26 +33,37 @@

const {
id,
label,
position = null,
overlay = 'assertive',
container = 'max-w-7xl',
onclose,
children,
}: ModalProps = $props()

let focusable = $state<HTMLElement[] | null>(null)
let dialogEl: HTMLElement | null = null
let wasActive = $state(false)

$effect(() => {
const isActive = active === id

if (wasActive && active === null && onclose) {
if (wasActive && !isActive && onclose) {
onclose()
}

wasActive = isActive
})

function focusables(): HTMLElement[] {
if (!dialogEl) {
return []
}

return Array.from(dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
el => !el.hasAttribute('disabled') && (el.checkVisibility?.() ?? true),
)
}

function alignment(position: ModalPosition | null) {
switch (position) {
case 'top-left':
Expand All @@ -76,20 +88,33 @@
}

function onKeydown(event: KeyboardEvent) {
if (event.code === 'Escape' && active) {
if (active !== id) {
return
}

if (event.code === 'Escape') {
close()
} else if (event.code === 'Tab' && active && focusable) {
const first = focusable[0]
const last = focusable[focusable.length - 1]
} else if (event.code === 'Tab' && overlay === 'assertive') {
const items = focusables()
const first = items[0]
const last = items[items.length - 1]

if (!first || !last) {
event.preventDefault()
return
}

const focused = document.activeElement
const outside = !(focused instanceof HTMLElement) || !dialogEl?.contains(focused)

if (event.shiftKey) {
if (document.activeElement === first) {
if (outside || focused === first) {
event.preventDefault()
last?.focus()
last.focus()
}
} else if (document.activeElement === last) {
} else if (outside || focused === last) {
event.preventDefault()
first?.focus()
first.focus()
}
}
}
Expand All @@ -112,19 +137,23 @@
}

function modal(el: HTMLElement) {
const previous = document.activeElement

let focusTimeout: ReturnType<typeof setTimeout> | null = null
let releaseScroll: (() => void) | null = null

if (overlay === 'assertive') {
focusable = FocusableSchema.parse(Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE)))
dialogEl = el

if (overlay === 'assertive') {
focusTimeout = setTimeout(() => {
releaseScroll = lockScroll()
el.focus()
}, 10)
}

return () => {
dialogEl = null

if (focusTimeout) {
clearTimeout(focusTimeout)
}
Expand All @@ -133,6 +162,10 @@
releaseScroll()
releaseScroll = null
}

if (previous instanceof HTMLElement) {
previous.focus()
}
}
}
</script>
Expand All @@ -148,23 +181,21 @@
tabindex="-1"
role="dialog"
aria-modal="true"
aria-label={label}
onclick={onBackdropClick}
onkeydown={onBackdropKeydown}
transition:blur={{ duration: 150 }}
{@attach modal}
>
{@attach modal}>
<div class="container {container}">
<div class="max-h-vh-90 overflow-auto">
<div
class="pointer-events-auto relative overflow-hidden"
class:border={overlay === 'polite'}
class:border-brand-blue-light={overlay === 'polite'}
>
class:border-neutral-300={overlay === 'polite'}>
<button
class="absolute top-4 right-4 z-10 flex items-center border bg-white p-0.5 transition hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-30"
aria-label="close modal"
onclick={close}
>
onclick={close}>
<Icon request={import('$fontawesome/solid/x.svg?raw')} class="size-4 fill-current" />
</button>
{@render children()}
Expand Down
3 changes: 1 addition & 2 deletions src/lib/components/common/Picture.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,5 @@
? `visibility:hidden;aspect-ratio:${width}/${height};max-width:${width}px;max-height:${height}px;`
: undefined}
onerror={() => (isLoading = false)}
onload={() => (isLoading = false)}
/>
onload={() => (isLoading = false)} />
</picture>
24 changes: 8 additions & 16 deletions src/lib/components/common/Video.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,7 @@
{#snippet preview()}
{#snippet icon()}
<div
class="inset-center group-hover:bg-brand-yellow absolute flex rounded-full bg-white p-4 text-black transition"
>
class="inset-center absolute flex rounded-full bg-white p-4 text-black transition group-hover:bg-neutral-200">
<Icon request={import('$fontawesome/solid/play.svg?raw')} class="size-8 shrink-0 fill-current" />
</div>
{/snippet}
Expand All @@ -135,33 +134,29 @@
onclick={playInline ? activateInline : openModal}
class="group relative w-full"
aria-label="play video"
style:--focusable-color="currentcolor"
>
style:--focusable-color="currentcolor">
{@render icon()}
<img
width={embed.width}
height={embed.height}
src={embed.image}
alt={embed.title}
class="m-0 aspect-video w-full rounded-lg object-cover"
loading="lazy"
/>
loading="lazy" />
</button>
{:else if upload}
<button
type="button"
onclick={playInline ? activateInline : openModal}
class="group relative w-full"
aria-label="play video"
style:--focusable-color="currentcolor"
>
style:--focusable-color="currentcolor">
{@render icon()}
<video
class="pointer-events-none block aspect-video w-full rounded-lg bg-black"
muted
playsinline
preload="metadata"
>
preload="metadata">
<source src={upload.src} type={upload.mime} />
</video>
</button>
Expand All @@ -173,8 +168,7 @@
<div
class="wrapper"
style:background-image="url({embed.image})"
style:--aspect-ratio="{embed.width}/{embed.height}"
>
style:--aspect-ratio="{embed.width}/{embed.height}">
<iframe
width={embed.width}
height={embed.height}
Expand All @@ -183,8 +177,7 @@
frameborder="0"
allowfullscreen
allow="autoplay; fullscreen; picture-in-picture; accelerometer; encrypted-media; gyroscope;"
{@attach postMessage('play')}
>
{@attach postMessage('play')}>
</iframe>
</div>
{:else if upload}
Expand All @@ -194,8 +187,7 @@
autoplay
playsinline
preload="metadata"
{@attach playVideo}
>
{@attach playVideo}>
<source src={upload.src} type={upload.mime} />
</video>
{/if}
Expand Down
40 changes: 36 additions & 4 deletions src/lib/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,47 @@ const modules = {
'x-svelte': () => import('$lib/sveltify'),
} satisfies Record<string, () => Promise<{ default: ModuleHandler }>>

interface Binding {
pass: number
cleanup: (() => void) | null
}

const bindings = new WeakMap<Document | Element, Map<string, Binding>>()

export default function init(scope: Document | Element): void {
let scopeBindings = bindings.get(scope)

if (!scopeBindings) {
scopeBindings = new Map()
bindings.set(scope, scopeBindings)
}

for (const [selector, request] of object.entries(modules)) {
const els = scope.querySelectorAll(selector)
const binding = scopeBindings.get(selector) ?? { pass: 0, cleanup: null }
const pass = ++binding.pass

if (els.length) {
request()
.then(({ default: module }) => module(els))
.catch(error => console.error(error))
scopeBindings.set(selector, binding)

if (!els.length) {
binding.cleanup?.()
binding.cleanup = null
continue
}

request()
.then(({ default: module }) => {
if (binding.pass !== pass) {
return
}

binding.cleanup?.()

const cleanup = module(els)

binding.cleanup = typeof cleanup === 'function' ? cleanup : null
})
.catch(error => console.error(error))
}

for (const el of scope.querySelectorAll('[target=_blank]')) {
Expand Down
Loading