title war fuer Hinweise die falsche Wahl: Es erscheint erst nach einer
Sekunde, sieht auf jedem System anders aus und laesst sich auf Touch-Geraeten
gar nicht aufrufen. Tippy war laengst als Abhaengigkeit da, aber kaum
benutzt.
<Button> nimmt jetzt eine tooltip-Prop, die zugleich als aria-label dient -
ohne das haetten die vielen Icon-Knoepfe beim Wegfall von title ihren Namen
fuer Screenreader verloren. Alle anderen Elemente nutzen die Action direkt.
Die Action selbst konnte bisher nur erzeugen. Sie beherrscht jetzt
Aktualisieren und Aufraeumen: Ein wechselnder Text ("Erledigt" / "Wieder
oeffnen") muss auch im Tooltip wechseln, und eine Instanz, die ihr Element
ueberlebt, haenge als leere Blase im Dokument.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
205 lines
6 KiB
TypeScript
205 lines
6 KiB
TypeScript
import 'tippy.js/animations/shift-toward-subtle.css'
|
|
import 'tippy.js/dist/tippy.css'
|
|
import tippy from 'tippy.js' // optional
|
|
import type {ClientResponseError} from 'pocketbase'
|
|
|
|
class AppStore {
|
|
theme: string = $state('dark')
|
|
|
|
showMobileNavigation: boolean = $state(false)
|
|
seo = $state({
|
|
title: 'Title',
|
|
description: 'Description',
|
|
})
|
|
|
|
// meta: {
|
|
// customer: CustomersResponse | undefined
|
|
// site: SitesResponse | undefined
|
|
// record: RecordsResponse | undefined
|
|
// contact: ContactsResponse | undefined
|
|
// file: FilesResponse | undefined
|
|
// user: UsersResponse | undefined
|
|
// } = $state({
|
|
// customer: undefined,
|
|
// site: undefined,
|
|
// record: undefined,
|
|
// contact: undefined,
|
|
// file: undefined,
|
|
// user: undefined,
|
|
// })
|
|
|
|
showGlobalActionMenu = $state(false)
|
|
|
|
confirm: {
|
|
show: boolean
|
|
title: string
|
|
text: string
|
|
error: boolean | ClientResponseError
|
|
yes: () => void
|
|
no: () => void
|
|
request: (props: any) => void
|
|
close: () => void
|
|
} = $state({
|
|
show: false,
|
|
title: '',
|
|
text: '',
|
|
error: false,
|
|
yes: () => {
|
|
},
|
|
no: () => {
|
|
},
|
|
|
|
request: (props: any) => {
|
|
this.confirm = {...this.confirm, ...props}
|
|
this.confirm.show = true
|
|
},
|
|
|
|
close: () => {
|
|
this.confirm.show = false
|
|
this.confirm.title = ''
|
|
this.confirm.text = ''
|
|
},
|
|
|
|
})
|
|
|
|
|
|
/**
|
|
|
|
Global Hotkey handler
|
|
|
|
Ignores hotkey handling when focus of user is in generic input-tags or [contenteditable]
|
|
|
|
@example
|
|
<script>
|
|
const keydownHandler = (e) => {
|
|
app.hotkey(e, e.key === 'Escape', () => {})
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={keydownHandler}/>
|
|
|
|
*/
|
|
hotkey = (event: KeyboardEvent, condition: boolean, callback: Function) => {
|
|
|
|
// Sicherstellen, dass `event` und `event.target` vorhanden sind
|
|
if (!event || !event.target) {
|
|
return
|
|
}
|
|
|
|
// Liste ignorierter Tag-Namen
|
|
const ignoredTags = ['INPUT', 'TEXTAREA', 'SELECT', 'OPTION']
|
|
|
|
// Standard-TAG-Prüfung mit optionaler Erweiterung
|
|
const isIgnoredTag = ignoredTags.includes((event.target as HTMLElement).tagName?.toUpperCase())
|
|
|
|
// Fokusierbare oder editierbare Inhalte ignorieren
|
|
const isEditable =
|
|
(event.target as HTMLElement).isContentEditable || // `contenteditable=true`
|
|
(event.target as HTMLElement).hasAttribute('contenteditable') // Attribut prüfen für dynamische Inhalte
|
|
|
|
if (isIgnoredTag || isEditable) {
|
|
// Rücksprung, keine Aktion ausführen
|
|
return
|
|
}
|
|
|
|
// Bedingung und Callback ausführen
|
|
if (condition) {
|
|
event.preventDefault() // Standardaktion abbrechen
|
|
callback() // Callback-Funktion ausführen
|
|
}
|
|
}
|
|
|
|
toggleTheme = () => {
|
|
this.theme = this.theme === 'dark' ? 'light' : 'dark'
|
|
}
|
|
}
|
|
|
|
export const app = new AppStore()
|
|
|
|
|
|
export const utils = {
|
|
|
|
getFileSize: async (fileUrl: string): Promise<string> => {
|
|
try {
|
|
const response = await fetch(fileUrl, {method: 'HEAD'})
|
|
const contentLength = response.headers.get('content-length')
|
|
const bytes = contentLength ? parseInt(contentLength, 10) : 0
|
|
const k = 1024
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
if (bytes === 0) return '0 Bytes'
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
} catch (error) {
|
|
console.error('Error fetching file size:', error)
|
|
return 'Unbekannt'
|
|
}
|
|
},
|
|
|
|
|
|
sanitize: (input: string) => {
|
|
return input.replace(/<\/?("[^"]*"|'[^']*'|[^>])*(>|$)/g, '')
|
|
},
|
|
|
|
|
|
excerpt: (input: string, length: number = 20) => {
|
|
if (input.length <= 20) return input
|
|
return input.substring(0, length) + '…'
|
|
},
|
|
|
|
slugify: (input: string) => {
|
|
if (!input) return ''
|
|
// make lower case and trim
|
|
var slug = input.toLowerCase().trim()
|
|
// remove accents from charaters
|
|
slug = slug.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
// replace invalid chars with spaces
|
|
slug = slug.replace(/[^a-z0-9\s-]/g, ' ').trim()
|
|
// replace multiple spaces or hyphens with a single hyphen
|
|
slug = slug.replace(/[\s-]+/g, '-')
|
|
return slug
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Tooltip für ein Element. Appweit die einzige Art, einen Hinweis an einen
|
|
* Knopf zu hängen — `title` überlassen wir dem Browser nicht mehr: Es
|
|
* erscheint erst nach einer Sekunde, sieht auf jedem System anders aus und
|
|
* lässt sich auf Touch-Geräten gar nicht aufrufen.
|
|
*
|
|
* Die Action beherrscht Aktualisieren und Aufräumen: Ein Text, der sich
|
|
* ändert (»Erledigt« / »Wieder öffnen«), muss auch im Tooltip wechseln, und
|
|
* eine Instanz, die ein entferntes Element überlebt, hinge als leere Blase
|
|
* im Dokument.
|
|
*/
|
|
export const tooltip = (
|
|
element: HTMLElement,
|
|
props: { active?: boolean; content?: string; showOnCreate?: boolean } = {},
|
|
) => {
|
|
const options = (p: typeof props) => ({
|
|
animation: 'shift-toward-subtle',
|
|
allowHTML: false,
|
|
content: p.content ?? '',
|
|
showOnCreate: p.showOnCreate,
|
|
})
|
|
|
|
const wanted = (p: typeof props) => p.active !== false && !!p.content?.trim()
|
|
|
|
let instance = wanted(props) ? tippy(element, options(props)) : null
|
|
|
|
return {
|
|
update(next: typeof props) {
|
|
if (!wanted(next)) {
|
|
instance?.destroy()
|
|
instance = null
|
|
return
|
|
}
|
|
|
|
if (instance) instance.setProps(options(next))
|
|
else instance = tippy(element, options(next))
|
|
},
|
|
destroy() {
|
|
instance?.destroy()
|
|
instance = null
|
|
},
|
|
}
|
|
}
|