@besaitech/ng-design-system 0.0.59 → 0.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -203,6 +203,50 @@ Import names are the `Sl…` classes; the markup selectors are `sds-…`.
203
203
  | `SdsTopbar` | `<sds-topbar>` | `appName`, `user`, `(logout)`; `[sdsLogo]` slot |
204
204
  | `SdsSidebar` + `SdsNavGroup` + `SdsNavItem` | `<sds-sidebar>` … | Collapsible nav rail; collapse state via root-provided `SidebarStore`; items render `routerLink` or `<button>` |
205
205
 
206
+ ## Theme & cross-app preferences
207
+
208
+ `SdsThemeService` holds the person's **mode** — `'system' | 'light' | 'dark'`, persisted to `localStorage`
209
+ under `sds-theme` — and resolves it to the painted **theme** (`light`/`dark`); `system` follows the OS live.
210
+ `toggle()` / `setTheme()` set an explicit theme, `setMode('system')` goes back to following the OS.
211
+
212
+ Apps of one product can carry the person's language and theme in the URL when they link to each other, so the
213
+ page that opens matches. Sending side:
214
+
215
+ ```ts
216
+ import { appendUiPrefs } from '@besaitech/ng-design-system';
217
+
218
+ appendUiPrefs('https://auth.example/continue#token=abc', { lang: translate.getCurrentLang(), theme: theme.mode() });
219
+ // → https://auth.example/continue?lang=fr&theme=dark#token=abc
220
+ ```
221
+
222
+ Never put them inside a nested redirect a server matches exactly (`return_to`, `redirect_uri`) — append them to
223
+ the outer URL. Receiving side: an inline script in `<head>` of `index.html` applies, persists and strips them
224
+ **before first paint** (no flash, and no import of this library at the root). `LANG_KEY` is the app's own
225
+ language storage key; keep `LANGS` in step with the app's languages. `readUiPrefs` / `stripUiPrefs` are the same
226
+ logic for code and tests.
227
+
228
+ ```html
229
+ <script>
230
+ (function () {
231
+ var LANG_KEY = 'app.lang', LANGS = ['en', 'fr', 'nl'], THEME_KEY = 'sds-theme', MODES = ['system', 'light', 'dark'];
232
+ var url = new URL(location.href), q = url.searchParams, lang = q.get('lang'), mode = q.get('theme');
233
+ try {
234
+ if (LANGS.indexOf(lang) >= 0) localStorage.setItem(LANG_KEY, lang);
235
+ if (MODES.indexOf(mode) >= 0) localStorage.setItem(THEME_KEY, mode);
236
+ } catch (e) {}
237
+ try { lang = localStorage.getItem(LANG_KEY) || lang; mode = localStorage.getItem(THEME_KEY) || mode; } catch (e) {}
238
+ if (LANGS.indexOf(lang) >= 0) document.documentElement.lang = lang;
239
+ var dark = mode === 'dark' || (mode !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
240
+ document.documentElement.classList.toggle('dark', dark);
241
+ document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
242
+ if (q.has('lang') || q.has('theme')) {
243
+ q.delete('lang'); q.delete('theme');
244
+ history.replaceState(history.state, '', url.pathname + url.search + url.hash);
245
+ }
246
+ })();
247
+ </script>
248
+ ```
249
+
206
250
  ## Labels & languages
207
251
 
208
252
  The library ships **no translations of its own**. Every visible or spoken string — placeholders,
@@ -15837,7 +15837,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
15837
15837
  }]
15838
15838
  }], propDecorators: { closeLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeLabel", required: false }] }] } });
15839
15839
 
15840
- const STORAGE_KEY = 'sds-theme';
15840
+ /** The localStorage key the mode is persisted under. Also read by the pre-paint boot script (see README). */
15841
+ const SDS_THEME_STORAGE_KEY = 'sds-theme';
15842
+ /** `true` for the three values {@link SdsThemeMode} accepts — for validating stored or URL-supplied input. */
15843
+ function isSdsThemeMode(value) {
15844
+ return value === 'system' || value === 'light' || value === 'dark';
15845
+ }
15841
15846
  /**
15842
15847
  * Light/dark theme state for the whole app. It owns exactly one thing: the presence of the `.dark`
15843
15848
  * class on the document root, which flips every design token defined under `.dark` in the theme CSS
@@ -15848,53 +15853,80 @@ const STORAGE_KEY = 'sds-theme';
15848
15853
  *
15849
15854
  * ```ts
15850
15855
  * private readonly theme = inject(SdsThemeService);
15851
- * this.theme.toggle(); // flip
15856
+ * this.theme.toggle(); // flip (to an explicit light/dark)
15857
+ * this.theme.setMode('system'); // follow the OS again
15852
15858
  * this.theme.isDark(); // read (a signal)
15859
+ * this.theme.mode(); // 'system' | 'light' | 'dark'
15853
15860
  * ```
15854
15861
  *
15855
- * Initial value: a previously stored choice wins; otherwise the OS `prefers-color-scheme` is honoured,
15856
- * so a first-time visitor already in dark at the OS level lands in dark. The choice is persisted to
15857
- * `localStorage` and re-applied on the next visit. Every browser access is guarded, so the service is
15858
- * safe under SSR / prerender (it simply no-ops without a DOM).
15862
+ * Two layers: {@link mode} is the choice (persisted to `localStorage` under `sds-theme`), {@link theme} is
15863
+ * what that resolves to. With no stored choice the mode is `system`, which tracks the OS setting live —
15864
+ * a first-time visitor in dark at the OS level lands in dark, and follows the OS if it flips. Every
15865
+ * browser access is guarded, so the service is safe under SSR / prerender (it simply no-ops without a DOM).
15859
15866
  */
15860
15867
  class SdsThemeService {
15861
15868
  document = inject(DOCUMENT);
15862
- theme = signal(this.initialTheme(), ...(ngDevMode ? [{ debugName: "theme" }] : []));
15869
+ /** The person's choice. */
15870
+ mode = signal(this.initialMode(), ...(ngDevMode ? [{ debugName: "mode" }] : []));
15871
+ /** The OS preference, kept live by a `matchMedia` listener. */
15872
+ systemDark = signal(this.queryDark()?.matches ?? false, ...(ngDevMode ? [{ debugName: "systemDark" }] : []));
15873
+ /** The theme painted: the explicit choice, or the OS one under `system`. */
15874
+ theme = computed(() => {
15875
+ const mode = this.mode();
15876
+ return mode === 'system' ? (this.systemDark() ? 'dark' : 'light') : mode;
15877
+ }, ...(ngDevMode ? [{ debugName: "theme" }] : []));
15863
15878
  isDark = computed(() => this.theme() === 'dark', ...(ngDevMode ? [{ debugName: "isDark" }] : []));
15864
15879
  constructor() {
15865
- // Keep the DOM class and the stored preference in lockstep with the signal — one effect, so any
15866
- // caller that sets the signal (toggle, setTheme, or a future settings screen) applies uniformly.
15867
- effect(() => this.apply(this.theme()));
15880
+ // Keep the DOM class and the stored choice in lockstep with the signals — one effect, so any caller
15881
+ // (toggle, setTheme, setMode, or the OS flipping under `system`) applies uniformly.
15882
+ effect(() => this.apply(this.theme(), this.mode()));
15883
+ const query = this.queryDark();
15884
+ if (query) {
15885
+ const onChange = (event) => this.systemDark.set(event.matches);
15886
+ query.addEventListener?.('change', onChange);
15887
+ inject(DestroyRef).onDestroy(() => query.removeEventListener?.('change', onChange));
15888
+ }
15868
15889
  }
15869
- /** Flip between light and dark. */
15890
+ /** Flip between light and dark (an explicit choice, leaving `system`). */
15870
15891
  toggle() {
15871
- this.theme.update((t) => (t === 'light' ? 'dark' : 'light'));
15892
+ this.mode.set(this.theme() === 'light' ? 'dark' : 'light');
15872
15893
  }
15873
15894
  /** Set an explicit theme. */
15874
15895
  setTheme(theme) {
15875
- this.theme.set(theme);
15896
+ this.mode.set(theme);
15876
15897
  }
15877
- initialTheme() {
15898
+ /** Set the choice, `system` included. */
15899
+ setMode(mode) {
15900
+ this.mode.set(mode);
15901
+ }
15902
+ initialMode() {
15878
15903
  try {
15879
- const stored = this.readStorage()?.getItem(STORAGE_KEY);
15880
- if (stored === 'light' || stored === 'dark')
15904
+ const stored = this.readStorage()?.getItem(SDS_THEME_STORAGE_KEY);
15905
+ if (isSdsThemeMode(stored))
15881
15906
  return stored;
15882
- const win = this.document.defaultView;
15883
- if (win?.matchMedia('(prefers-color-scheme: dark)').matches)
15884
- return 'dark';
15885
15907
  }
15886
15908
  catch {
15887
- /* no storage / no matchMedia (SSR, privacy mode) — fall through to the light default */
15909
+ /* no storage (SSR, privacy mode) — fall through to following the OS */
15910
+ }
15911
+ return 'system';
15912
+ }
15913
+ queryDark() {
15914
+ try {
15915
+ return this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)') ?? null;
15916
+ }
15917
+ catch {
15918
+ return null;
15888
15919
  }
15889
- return 'light';
15890
15920
  }
15891
- apply(theme) {
15921
+ apply(theme, mode) {
15892
15922
  const root = this.document.documentElement;
15893
15923
  root.classList.toggle('dark', theme === 'dark');
15894
15924
  // Let the UA paint form controls / scrollbars to match.
15895
15925
  root.style.colorScheme = theme;
15896
15926
  try {
15897
- this.readStorage()?.setItem(STORAGE_KEY, theme);
15927
+ // The CHOICE is stored, not the resolved theme — `system` must stay `system`, or the first visit
15928
+ // would freeze whatever the OS happened to be set to.
15929
+ this.readStorage()?.setItem(SDS_THEME_STORAGE_KEY, mode);
15898
15930
  }
15899
15931
  catch {
15900
15932
  /* storage unavailable — the class is still applied; only persistence is skipped */
@@ -15984,6 +16016,69 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
15984
16016
  }]
15985
16017
  }], propDecorators: { size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], darkLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "darkLabel", required: false }] }], lightLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "lightLabel", required: false }] }] } });
15986
16018
 
16019
+ const SDS_UI_PREF_LANG_PARAM = 'lang';
16020
+ const SDS_UI_PREF_THEME_PARAM = 'theme';
16021
+ const ABSOLUTE = /^[a-z][a-z0-9+.-]*:/i;
16022
+ const PLACEHOLDER_ORIGIN = 'http://sds.invalid';
16023
+ /**
16024
+ * `url` with `lang` / `theme` set on its query (overwriting any present), the fragment kept. Accepts an
16025
+ * absolute URL or a root-relative path (returned root-relative). Invalid themes are dropped, not sent.
16026
+ *
16027
+ * ```ts
16028
+ * appendUiPrefs('https://auth.example/continue#token=abc', { lang: 'fr', theme: 'dark' })
16029
+ * // → 'https://auth.example/continue?lang=fr&theme=dark#token=abc'
16030
+ * ```
16031
+ */
16032
+ function appendUiPrefs(url, prefs) {
16033
+ const absolute = ABSOLUTE.test(url);
16034
+ let parsed;
16035
+ try {
16036
+ parsed = new URL(url, PLACEHOLDER_ORIGIN);
16037
+ }
16038
+ catch {
16039
+ return url;
16040
+ }
16041
+ const lang = prefs.lang?.trim();
16042
+ if (lang) {
16043
+ parsed.searchParams.set(SDS_UI_PREF_LANG_PARAM, lang);
16044
+ }
16045
+ if (isSdsThemeMode(prefs.theme)) {
16046
+ parsed.searchParams.set(SDS_UI_PREF_THEME_PARAM, prefs.theme);
16047
+ }
16048
+ return absolute ? parsed.toString() : parsed.pathname + parsed.search + parsed.hash;
16049
+ }
16050
+ /**
16051
+ * The prefs carried by a query string (`location.search`), validated: `lang` only if it is one of
16052
+ * `supportedLangs`, `theme` only if it is a {@link SdsThemeMode}. Anything else reads as absent.
16053
+ */
16054
+ function readUiPrefs(search, supportedLangs) {
16055
+ const params = new URLSearchParams(search);
16056
+ const lang = params.get(SDS_UI_PREF_LANG_PARAM);
16057
+ const theme = params.get(SDS_UI_PREF_THEME_PARAM);
16058
+ return {
16059
+ lang: lang && supportedLangs.includes(lang) ? lang : null,
16060
+ theme: isSdsThemeMode(theme) ? theme : null,
16061
+ };
16062
+ }
16063
+ /** `url` without the `lang` / `theme` params (everything else, fragment included, untouched). */
16064
+ function stripUiPrefs(url) {
16065
+ const absolute = ABSOLUTE.test(url);
16066
+ let parsed;
16067
+ try {
16068
+ parsed = new URL(url, PLACEHOLDER_ORIGIN);
16069
+ }
16070
+ catch {
16071
+ return url;
16072
+ }
16073
+ if (!parsed.searchParams.has(SDS_UI_PREF_LANG_PARAM) &&
16074
+ !parsed.searchParams.has(SDS_UI_PREF_THEME_PARAM)) {
16075
+ return url;
16076
+ }
16077
+ parsed.searchParams.delete(SDS_UI_PREF_LANG_PARAM);
16078
+ parsed.searchParams.delete(SDS_UI_PREF_THEME_PARAM);
16079
+ return absolute ? parsed.toString() : parsed.pathname + parsed.search + parsed.hash;
16080
+ }
16081
+
15987
16082
  /**
15988
16083
  * Shared collapse state for the sidebar and its nav items.
15989
16084
  *
@@ -18322,5 +18417,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
18322
18417
  * Generated bundle index. Do not edit.
18323
18418
  */
18324
18419
 
18325
- export { SDS_ALLERGENS, SDS_ALLERGEN_ICONS, SDS_ALLERGEN_LABELS_EN, SDS_ICONS, SDS_PHONE_COUNTRIES, SdsAccordion, SdsAccordionContent, SdsAccordionHeader, SdsAccordionPanel, SdsAccordionStore, SdsAddressAutocomplete, SdsAddressExtra, SdsAllergenIcon, SdsAvatar, SdsBadge, SdsBulkBar, SdsButton, SdsCalendar, SdsCard, SdsCheckbox, SdsChecklistRow, SdsChip, SdsChipGroup, SdsDatePicker, SdsFileDrop, SdsIcon, SdsImageCropper, SdsInput, SdsInputNumber, SdsLanguageSwitcher, SdsLocalizedInput, SdsMenu, SdsMenuItem, SdsMenuTrigger, SdsMultiSelect, SdsNavGroup, SdsNavItem, SdsOtpInput, SdsPaginator, SdsPhoneInput, SdsRadio, SdsScore, SdsSearchCommand, SdsSegmented, SdsSelect, SdsSelectEmpty, SdsSelectFooter, SdsSelectOption, SdsShell, SdsSidebar, SdsSortableHandle, SdsSortableItem, SdsSortableList, SdsStatusTile, SdsTab, SdsTabList, SdsTabPanel, SdsTabPanels, SdsTable, SdsTableCell, SdsTableEmpty, SdsTableHeader, SdsTableRowExpansion, SdsTabs, SdsTabsStore, SdsTextarea, SdsThemeService, SdsThemeToggle, SdsTimeInput, SdsToastHost, SdsToastService, SdsToggleSwitch, SdsTopbar, SdsTriStateChip, SdsUserMenu, SidebarStore, addDays, addMonths, addMonthsKeepDay, formatDdMmYyyy, formatHhMm, isOutOfRange, isSameDay, isoDay, pad2, sdsFileMatchesAccept, sdsFormatBytes, sdsFormatNational, sdsMatchDialCode, sdsNormalizeSignificant, startOfDay, startOfMonth };
18420
+ export { SDS_ALLERGENS, SDS_ALLERGEN_ICONS, SDS_ALLERGEN_LABELS_EN, SDS_ICONS, SDS_PHONE_COUNTRIES, SDS_THEME_STORAGE_KEY, SDS_UI_PREF_LANG_PARAM, SDS_UI_PREF_THEME_PARAM, SdsAccordion, SdsAccordionContent, SdsAccordionHeader, SdsAccordionPanel, SdsAccordionStore, SdsAddressAutocomplete, SdsAddressExtra, SdsAllergenIcon, SdsAvatar, SdsBadge, SdsBulkBar, SdsButton, SdsCalendar, SdsCard, SdsCheckbox, SdsChecklistRow, SdsChip, SdsChipGroup, SdsDatePicker, SdsFileDrop, SdsIcon, SdsImageCropper, SdsInput, SdsInputNumber, SdsLanguageSwitcher, SdsLocalizedInput, SdsMenu, SdsMenuItem, SdsMenuTrigger, SdsMultiSelect, SdsNavGroup, SdsNavItem, SdsOtpInput, SdsPaginator, SdsPhoneInput, SdsRadio, SdsScore, SdsSearchCommand, SdsSegmented, SdsSelect, SdsSelectEmpty, SdsSelectFooter, SdsSelectOption, SdsShell, SdsSidebar, SdsSortableHandle, SdsSortableItem, SdsSortableList, SdsStatusTile, SdsTab, SdsTabList, SdsTabPanel, SdsTabPanels, SdsTable, SdsTableCell, SdsTableEmpty, SdsTableHeader, SdsTableRowExpansion, SdsTabs, SdsTabsStore, SdsTextarea, SdsThemeService, SdsThemeToggle, SdsTimeInput, SdsToastHost, SdsToastService, SdsToggleSwitch, SdsTopbar, SdsTriStateChip, SdsUserMenu, SidebarStore, addDays, addMonths, addMonthsKeepDay, appendUiPrefs, formatDdMmYyyy, formatHhMm, isOutOfRange, isSameDay, isSdsThemeMode, isoDay, pad2, readUiPrefs, sdsFileMatchesAccept, sdsFormatBytes, sdsFormatNational, sdsMatchDialCode, sdsNormalizeSignificant, startOfDay, startOfMonth, stripUiPrefs };
18326
18421
  //# sourceMappingURL=besaitech-ng-design-system.mjs.map