mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Fix page modals leaking between pages, redirect link gate and inline modal scripts (#3087)
This commit is contained in:
1 parent
090591873a
commit
bf576f10a4
8 files changed
+73
-45
No files matched your search
@@ -2,7 +2,7 @@
|
||||
// Validates internal links in the docs at the source level (no build needed):
|
||||
// - markdown links and `related:` frontmatter in docs/content/**/*.mdx
|
||||
// - menu/link urls in shared/data/docs.json
|
||||
// - redirect destinations in docs/astro.config.mjs
|
||||
// - redirect destinations in docs/lib/redirects.ts
|
||||
// - href string literals in docs .astro components
|
||||
// - docs paths linked from the demo site: <DocsLink path="…"> and getDocsUrl('…')
|
||||
// - asset paths that actually render: src, poster and CSS url() in mdx and in
|
||||
@@ -20,6 +20,7 @@ import { join, dirname, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { sync } from 'glob'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import { redirects } from '../docs/lib/redirects.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = join(__dirname, '..')
|
||||
@@ -53,19 +54,10 @@ for (const file of sync(join(pagesDir, '**', '*.ts'))) {
|
||||
}
|
||||
|
||||
// Redirect sources are valid link targets; their destinations must exist.
|
||||
const astroConfig = readFileSync(join(repoRoot, 'docs', 'astro.config.mjs'), 'utf8')
|
||||
const redirectSources = new Set<string>()
|
||||
const redirectDestinations: string[] = []
|
||||
// static entries: '/from': { status: 301, destination: '/to' }
|
||||
for (const match of astroConfig.matchAll(/'(\/[^']+)':\s*\{\s*status:\s*\d+,\s*destination:\s*'(\/[^']+)'/g)) {
|
||||
redirectSources.add(match[1] ?? '')
|
||||
redirectDestinations.push(match[2] ?? '')
|
||||
}
|
||||
// generated entries: ['alerts', 'alert'], … mapped onto /ui/components/<slug>
|
||||
for (const match of astroConfig.matchAll(/\['([a-z-]+)',\s*'([a-z-]+)'\]/g)) {
|
||||
redirectSources.add(`/ui/components/${match[1]}`)
|
||||
redirectDestinations.push(`/ui/components/${match[2]}`)
|
||||
}
|
||||
// The table lives in docs/lib/redirects.ts (shared with astro.config.mjs and
|
||||
// the docs middleware), so it is imported rather than parsed.
|
||||
const redirectSources = new Set<string>(Object.keys(redirects))
|
||||
const redirectDestinations: string[] = Object.values(redirects).map((redirect) => redirect.destination)
|
||||
|
||||
// Asset urls are served from these source directories (synced by copyAssets).
|
||||
const assetRoots: Record<string, string[]> = {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tabler/preview": patch
|
||||
---
|
||||
|
||||
Fixed page modals leaking between pages and made the change-password and confirm-delete modal scripts plain inline JS.
|
||||
@@ -4,5 +4,5 @@
|
||||
// Registration is synchronous (a promise) — see page-modals.ts.
|
||||
import { addPageModal } from '@shared/lib/page-modals'
|
||||
|
||||
addPageModal(Astro.slots.render('default'))
|
||||
addPageModal(Astro.request, Astro.slots.render('default'))
|
||||
---
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
// Separate component so drainPageModals() executes only at this point of the
|
||||
// Separate component so drainPageModals(Astro.request) executes only at this point of the
|
||||
// render stream (after the page content render has STARTED — registrations are
|
||||
// synchronous, modal content may be a promise).
|
||||
import { drainPageModals } from '@shared/lib/page-modals'
|
||||
|
||||
const modals = await Promise.all(drainPageModals())
|
||||
const modals = await Promise.all(drainPageModals(Astro.request))
|
||||
---
|
||||
|
||||
{
|
||||
|
||||
@@ -30,14 +30,18 @@ import Strength from '@ui/Strength.astro'
|
||||
</form>
|
||||
</div>
|
||||
<!-- BEGIN CHANGE PASSWORD -->
|
||||
<script>
|
||||
<script is:inline>
|
||||
// Password show/hide toggles are wired generically by InputGroup.astro (data-password-toggle).
|
||||
// The strength meter is wired by tabler.js from data-bs-strength.
|
||||
const newPasswordInput = document.getElementById('password-new') as HTMLInputElement | null
|
||||
const confirmPasswordInput = document.getElementById('password-confirm') as HTMLInputElement | null
|
||||
const matchError = document.getElementById('password-match-error')
|
||||
function initChangePassword() {
|
||||
const newPasswordInput = document.getElementById('password-new')
|
||||
const confirmPasswordInput = document.getElementById('password-confirm')
|
||||
const matchError = document.getElementById('password-match-error')
|
||||
|
||||
if (!newPasswordInput || !confirmPasswordInput || !matchError) {
|
||||
return
|
||||
}
|
||||
|
||||
if (newPasswordInput && confirmPasswordInput && matchError) {
|
||||
const validateMatch = () => {
|
||||
const newPassword = newPasswordInput.value
|
||||
const confirmPassword = confirmPasswordInput.value
|
||||
@@ -58,5 +62,7 @@ import Strength from '@ui/Strength.astro'
|
||||
newPasswordInput.addEventListener('input', validateMatch)
|
||||
confirmPasswordInput.addEventListener('input', validateMatch)
|
||||
}
|
||||
|
||||
document.readyState !== 'loading' ? initChangePassword() : document.addEventListener('DOMContentLoaded', initChangePassword, { once: true })
|
||||
</script>
|
||||
<!-- END CHANGE PASSWORD -->
|
||||
@@ -43,14 +43,18 @@ import ModalClose from './ModalClose.astro'
|
||||
</div>
|
||||
</div>
|
||||
<!-- BEGIN CONFIRM DELETE -->
|
||||
<script>
|
||||
const checkbox = document.getElementById('confirm-delete-checkbox') as HTMLInputElement | null
|
||||
const deleteButton = document.getElementById('confirm-delete-button') as HTMLButtonElement | null
|
||||
<script is:inline>
|
||||
function initConfirmDelete() {
|
||||
const checkbox = document.getElementById('confirm-delete-checkbox')
|
||||
const deleteButton = document.getElementById('confirm-delete-button')
|
||||
|
||||
if (checkbox && deleteButton) {
|
||||
checkbox.addEventListener('change', function (this: HTMLInputElement) {
|
||||
deleteButton.disabled = !this.checked
|
||||
})
|
||||
if (checkbox && deleteButton) {
|
||||
checkbox.addEventListener('change', function () {
|
||||
deleteButton.disabled = !this.checked
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.readyState !== 'loading' ? initConfirmDelete() : document.addEventListener('DOMContentLoaded', initConfirmDelete, { once: true })
|
||||
</script>
|
||||
<!-- END CONFIRM DELETE -->
|
||||
@@ -3,15 +3,28 @@ import { addPageModal, drainPageModals } from './page-modals'
|
||||
|
||||
describe('page-modals registry', () => {
|
||||
it('drains registrations in insertion order and empties the registry', () => {
|
||||
addPageModal('<div>a</div>')
|
||||
addPageModal('<div>b</div>')
|
||||
expect(drainPageModals()).toEqual(['<div>a</div>', '<div>b</div>'])
|
||||
expect(drainPageModals()).toEqual([])
|
||||
const render = {}
|
||||
addPageModal(render, '<div>a</div>')
|
||||
addPageModal(render, '<div>b</div>')
|
||||
expect(drainPageModals(render)).toEqual(['<div>a</div>', '<div>b</div>'])
|
||||
expect(drainPageModals(render)).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes identical registrations (same modal declared twice renders once)', () => {
|
||||
addPageModal('<div>same</div>')
|
||||
addPageModal('<div>same</div>')
|
||||
expect(drainPageModals()).toHaveLength(1)
|
||||
it('passes promises through untouched', async () => {
|
||||
const render = {}
|
||||
const pending = Promise.resolve('<div>later</div>')
|
||||
addPageModal(render, pending)
|
||||
const drained = drainPageModals(render)
|
||||
expect(drained[0]).toBe(pending)
|
||||
await expect(drained[0]).resolves.toBe('<div>later</div>')
|
||||
})
|
||||
|
||||
it('keeps the modals of one render away from another', () => {
|
||||
const pageA = {}
|
||||
const pageB = {}
|
||||
addPageModal(pageA, '<div>a</div>')
|
||||
addPageModal(pageB, '<div>b</div>')
|
||||
expect(drainPageModals(pageB)).toEqual(['<div>b</div>'])
|
||||
expect(drainPageModals(pageA)).toEqual(['<div>a</div>'])
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,28 @@
|
||||
// Modals declared in page content render at the end of <body>, before the
|
||||
// settings panel (via PageModals).
|
||||
//
|
||||
// One registry per render, keyed by the request object (like page-scripts):
|
||||
// Astro renders pages concurrently in the build and the dev server, so a
|
||||
// module-level list would hand one page's modals to another.
|
||||
//
|
||||
// A string OR a promise of a string is registered: CaptureModal must register
|
||||
// itself SYNCHRONOUSLY in its frontmatter (before it first yields control via
|
||||
// await), because Astro renders siblings concurrently and PageModals may drain
|
||||
// the registry before the asynchronous render of the slot completes.
|
||||
const modals: Array<string | Promise<string>> = []
|
||||
type Modal = string | Promise<string>
|
||||
|
||||
export function addPageModal(html: string | Promise<string>): void {
|
||||
if (!modals.includes(html)) {
|
||||
modals.push(html)
|
||||
}
|
||||
const registries = new WeakMap<object, Modal[]>()
|
||||
|
||||
export function addPageModal(render: object, html: Modal): void {
|
||||
// No dedup: every slots.render() is a new promise, so identical markup
|
||||
// could never be told apart here anyway.
|
||||
const modals = registries.get(render) ?? []
|
||||
modals.push(html)
|
||||
registries.set(render, modals)
|
||||
}
|
||||
|
||||
export function drainPageModals(): Array<string | Promise<string>> {
|
||||
const out = [...modals]
|
||||
modals.length = 0
|
||||
export function drainPageModals(render: object): Modal[] {
|
||||
const out = registries.get(render) ?? []
|
||||
registries.delete(render)
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user