diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0e2d8..dc2c95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ Changelog ist die Upgrade-Anleitung für die Tools. ## [Unreleased] +### @basicbar/ui (→ wird `ui/v0.6.0`) + +**Geteilte Einstellungs-Bausteine** (ausleihbar#35): `LanguageOptions`, +`AppearanceControl` und `PreferencesMenu`. Die ersten beiden sind Menüzeilen +für das Account-Menü des Tools (je eine beschriftete `role="group"` mit +`menuitemradio`-Zeilen, gültig in `role="menu"`; `LanguageOptions` hat +`heading?`); `PreferencesMenu` ist der runde Einstellungen-Button mit +Popover für nicht angemeldete Besucher. Es gibt bewusst keinen Eintrag „Auto“ +bei der Sprache: ohne Wahl folgt der Detector dem System (die angezeigte +Sprache ist markiert), eine Wahl wird von i18next gecacht und ist damit +verbindlich. `onChange` / `onLanguageChange` dienen zum serverseitigen +Speichern. README: neuer Abschnitt „Preferences (language & appearance)“. + +Migration: additiv, nichts zu tun. Die Tools ergänzen in ihren Katalogen den +Schlüssel „Preferences“ (die übrigen — Appearance, Auto, „(follows your +system)“, Light, Dark, Language — existieren meist schon). + ### @basicbar/ui (→ wird `ui/v0.5.1`) **CSP-tauglicher `RichTextEditor`** (basicbar#9): TipTap hängte beim Mounten diff --git a/packages/ui/README.md b/packages/ui/README.md index 0faba5b..e345465 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -36,7 +36,11 @@ Tools identisch aussehen, übergeben sie dieselbe Ramp. import { createPreset } from "@basicbar/ui/tailwind-preset"; export default { presets: [createPreset({ colors: { slate: {/* Tool-Ramp */}, brand: {/* Tool-Ramp */} } })], - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + "./node_modules/@basicbar/ui/dist/**/*.js", + ], }; // index.css @@ -48,6 +52,10 @@ import { initI18n, ThemeProvider } from "@basicbar/ui"; initI18n({ resources: { en, de } }); ``` +Der `dist`-Glob ist nötig, weil die Komponenten des Pakets (z. B. +`RichTextEditor`, `PreferencesMenu`) ihre Tailwind-Klassen im Paket-Code +tragen — ohne den Glob erzeugt Tailwind dafür kein CSS. + Build: `npm install && npm run build` (tsup → `dist/`). Distribution als npm-Tarball über ein GitHub-Release-Asset (siehe ADR-0002, ADR-0004 und Repo-CI). @@ -175,6 +183,36 @@ siehe `initI18n`): `"Bold"`, `"Italic"`, `"Heading (large)"`, `"Image upload failed"`, `"Image description (alt text)"`, `"Image description"`. +## Preferences (language & appearance) + +Three shared components, styled like the rest of the -bar menus: + +- `LanguageOptions` (`{ onChange?, onPicked?, heading? }`) — a labelled + `role="group"` with its "Language" heading (`heading={false}` hides it) and + `menuitemradio` rows from `SUPPORTED_LANGUAGES`. +- `AppearanceControl` — a labelled `role="group"` with its "Appearance" + heading and Auto / Light / Dark as `menuitemradio` rows (needs + `ThemeProvider`). Both are valid inside a `role="menu"`. +- `PreferencesMenu` (`{ onLanguageChange? }`) — round button with a popover + holding both (groups separated by a `role="separator"`); for signed-out visitors. + +```tsx +// Signed-in: rows inside the app's own account menu + api.setLanguage(lang)} onPicked={close} /> + + +// Guests + +``` + +Language rule: there is no "Auto" entry. Without a manual pick the detector +follows the system and the shown language is the marked one; a pick calls +`i18n.changeLanguage`, which the detector caches, so it is binding from then +on. Use `onChange` / `onLanguageChange` to persist the choice server-side. + +Translation keys (English source strings): `Appearance`, `Auto`, +`(follows your system)`, `Light`, `Dark`, `Language`, `Preferences`. + ## CSP Die Komponenten injizieren zur Laufzeit **keine** Inline-Styles oder diff --git a/packages/ui/package-lock.json b/packages/ui/package-lock.json index a74fed9..be8005e 100644 --- a/packages/ui/package-lock.json +++ b/packages/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@basicbar/ui", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@basicbar/ui", - "version": "0.5.1", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@tiptap/extension-bold": "^3.27.2", diff --git a/packages/ui/package.json b/packages/ui/package.json index e4c1af1..693dd92 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@basicbar/ui", - "version": "0.5.1", + "version": "0.6.0", "description": "Design-System-Basis der virtUOS -bar-Tools: Tailwind-Preset, Basis-Styles, Theme (Dark Mode), i18n-Bootstrap, contentLang, RichTextEditor/RichText (TipTap)", "license": "Apache-2.0", "author": "Universität Osnabrück (virtUOS)", diff --git a/packages/ui/src/Preferences.tsx b/packages/ui/src/Preferences.tsx new file mode 100644 index 0000000..1fce71d --- /dev/null +++ b/packages/ui/src/Preferences.tsx @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Universität Osnabrück (virtUOS) + +/** Shared preference controls for the -bar tools: language and appearance, + * either as rows inside the app's own account menu (`LanguageOptions` + + * `AppearanceControl`) or, for signed-out visitors, as a round popover + * button (`PreferencesMenu`). */ +import { Check, Monitor, Moon, SlidersHorizontal, Sun } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SUPPORTED_LANGUAGES } from "./i18n"; +import { useTheme, type Appearance } from "./theme"; + +/** Auto / Light / Dark setting (a labelled `role="group"` of `menuitemradio` + * rows, valid inside a `role="menu"`). Auto follows the OS; an explicit pick + * overrides it. Full-width radio rows (icon + label + check on the active + * option) to match the menu vocabulary. */ +export function AppearanceControl() { + const { t } = useTranslation(); + const { appearance, setAppearance } = useTheme(); + const headingId = useId(); + const options: { + value: Appearance; + label: string; + hint?: string; + icon: typeof Sun; + }[] = [ + { value: "auto", label: t("Auto"), hint: t("(follows your system)"), icon: Monitor }, + { value: "light", label: t("Light"), icon: Sun }, + { value: "dark", label: t("Dark"), icon: Moon }, + ]; + return ( +
+

+ {t("Appearance")} +

+ {options.map((opt) => { + const active = appearance === opt.value; + return ( + + ); + })} +
+ ); +} + +/** Language options as `menuitemradio` rows in a labelled `role="group"`. There is deliberately no "Auto" + * entry: without a manual pick the detector follows the system and the + * resolved language is the one marked; a pick is cached by i18next (the + * detector's cache) and is therefore binding from then on. `onChange` lets + * the app persist the choice server-side; `onPicked` lets a menu close. */ +export function LanguageOptions({ + onChange, + onPicked, + heading = true, +}: { + onChange?: (lang: string) => void; + onPicked?: () => void; + /** Render the visible "Language" heading (default). */ + heading?: boolean; +}) { + const { t, i18n } = useTranslation(); + const headingId = useId(); + const resolved = i18n.resolvedLanguage ?? i18n.language; + const current = SUPPORTED_LANGUAGES.some((l) => l.code === resolved) + ? resolved + : SUPPORTED_LANGUAGES[0]?.code; + return ( +
+ {heading && ( +

+ {t("Language")} +

+ )} + {SUPPORTED_LANGUAGES.map((lang) => ( + + ))} +
+ ); +} + +/** Round "preferences" button with a popover holding language + appearance — + * for signed-out visitors (signed-in users get the same rows in their + * account menu). */ +export function PreferencesMenu({ + onLanguageChange, +}: { + onLanguageChange?: (lang: string) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onPointerDown(event: PointerEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + setOpen(false); + } + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + triggerRef.current?.focus(); + } + } + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + function closeAndFocus() { + setOpen(false); + triggerRef.current?.focus(); + } + + return ( +
+ + {open && ( +
+ +
+ +
+ )} +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f50a719..c95698b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -21,6 +21,8 @@ export type { export { TranslatableField } from "./TranslatableField"; export type { RenderInputArgs, TranslatableFieldProps } from "./TranslatableField"; +export { AppearanceControl, LanguageOptions, PreferencesMenu } from "./Preferences"; + export { RichTextEditor } from "./RichTextEditor"; export type { RichTextEditorProps } from "./RichTextEditor"; export { RichText } from "./RichText";