Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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
<LanguageOptions onChange={(lang) => api.setLanguage(lang)} onPicked={close} />
<AppearanceControl />

// Guests
<PreferencesMenu />
```

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
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down
187 changes: 187 additions & 0 deletions packages/ui/src/Preferences.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="group" aria-labelledby={headingId}>
<p id={headingId} className="px-3 pb-0.5 pt-1 text-xs text-slate-400 dark:text-slate-300">
{t("Appearance")}
</p>
{options.map((opt) => {
const active = appearance === opt.value;
return (
<button
key={opt.value}
type="button"
role="menuitemradio"
aria-checked={active}
onClick={() => setAppearance(opt.value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-700"
>
<opt.icon aria-hidden className="h-4 w-4 text-slate-400" />
<span className="flex-1">
{opt.label}
{opt.hint && (
<span className="block text-xs text-slate-400 dark:text-slate-300">
{opt.hint}
</span>
)}
</span>
{active && <Check aria-hidden className="h-4 w-4 text-brand-600" />}
</button>
);
})}
</div>
);
}

/** 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 (
<div
role="group"
aria-labelledby={heading ? headingId : undefined}
aria-label={heading ? undefined : t("Language")}
>
{heading && (
<p
id={headingId}
className="px-3 pb-0.5 pt-1 text-xs text-slate-400 dark:text-slate-300"
>
{t("Language")}
</p>
)}
{SUPPORTED_LANGUAGES.map((lang) => (
<button
key={lang.code}
type="button"
role="menuitemradio"
aria-checked={lang.code === current}
onClick={() => {
i18n.changeLanguage(lang.code);
onChange?.(lang.code);
onPicked?.();
}}
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-700"
>
{lang.label}
{lang.code === current && (
<Check aria-hidden className="h-4 w-4 text-brand-600" />
)}
</button>
))}
</div>
);
}

/** 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<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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 (
<div className="relative" ref={ref}>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={t("Preferences")}
className="flex h-9 w-9 items-center justify-center rounded-full sm:h-10 sm:w-10 text-slate-600 transition-colors duration-150 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<SlidersHorizontal aria-hidden className="h-5 w-5" />
</button>
{open && (
<div
role="menu"
className="absolute right-0 z-30 mt-2 w-56 animate-fade-up overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg shadow-slate-900/5 dark:border-slate-700 dark:bg-slate-800"
>
<LanguageOptions onChange={onLanguageChange} onPicked={closeAndFocus} />
<div role="separator" className="my-1 border-t border-slate-100 dark:border-slate-700" />
<AppearanceControl />
</div>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading