@letsprogram/ng-oat 0.1.1 → 0.1.3

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.
@@ -1,35 +1,39 @@
1
1
  import * as i0 from '@angular/core';
2
- import { makeEnvironmentProviders, provideAppInitializer, inject, PLATFORM_ID, Injectable, ElementRef, DestroyRef, signal, output, afterNextRender, Directive, InjectionToken, Renderer2, ViewContainerRef, input, TemplateRef, effect, computed, Component, model, CUSTOM_ELEMENTS_SCHEMA, viewChild, viewChildren, contentChildren } from '@angular/core';
3
- import { DOCUMENT, isPlatformBrowser } from '@angular/common';
2
+ import { makeEnvironmentProviders, provideAppInitializer, inject, PLATFORM_ID, Injectable, ElementRef, DestroyRef, signal, output, afterNextRender, Directive, InjectionToken, Renderer2, ViewContainerRef, input, TemplateRef, effect, computed, Component, model, CUSTOM_ELEMENTS_SCHEMA, viewChild, viewChildren, contentChildren, contentChild } from '@angular/core';
3
+ import { DOCUMENT, isPlatformBrowser, NgTemplateOutlet, NgSwitch, NgSwitchCase, NgSwitchDefault } from '@angular/common';
4
4
 
5
5
  const DEFAULT_OPTIONS = {
6
- assets: { css: 'auto', js: 'auto' },
7
- registerWebComponents: true,
6
+ assets: { css: false },
8
7
  basePath: 'assets/oat',
9
8
  };
10
- function injectTag(doc, tag, attrs, id) {
9
+ function injectTag(doc, attrs, id) {
11
10
  return new Promise((resolve, reject) => {
12
11
  if (doc.getElementById(id)) {
13
12
  resolve();
14
13
  return;
15
14
  }
16
- const el = doc.createElement(tag);
15
+ const el = doc.createElement('link');
17
16
  el.id = id;
18
17
  Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
19
18
  el.addEventListener('load', () => resolve());
20
- el.addEventListener('error', () => reject(new Error(`Failed to load ${tag}: ${attrs['href'] || attrs['src']}`)));
19
+ el.addEventListener('error', () => reject(new Error(`Failed to load link: ${attrs['href']}`)));
21
20
  doc.head.appendChild(el);
22
21
  });
23
22
  }
24
23
  /**
25
- * Provides Oat UI assets and initialisation.
24
+ * Provides ng-oat core initialisation.
25
+ *
26
+ * By default, no runtime asset injection occurs — add CSS via
27
+ * `angular.json` `styles` array or `@import` in `styles.css` (recommended).
28
+ * Oat JS is no longer needed; all behavior is handled natively by Angular components.
26
29
  *
27
- * Include in your app config providers array:
28
30
  * ```ts
29
- * provideNgOat() // auto-inject CSS + JS
30
- * provideNgOat({ assets: { css: false } }) // user imported CSS manually
31
- * provideNgOat({ basePath: '/custom/oat' }) // custom asset location
31
+ * provideNgOat() // recommended (CSS via angular.json / styles.css)
32
+ * provideNgOat({ assets: { css: 'link' } }) // opt-in runtime CSS injection *
32
33
  * ```
34
+ *
35
+ * \\* Runtime injection requires an assets glob in angular.json to copy files:
36
+ * `{ glob: '**\\/*', input: 'node_modules/@letsprogram/ng-oat/assets/oat', output: '/assets/oat' }`
33
37
  */
34
38
  function provideNgOat(options) {
35
39
  const opts = {
@@ -45,19 +49,12 @@ function provideNgOat(options) {
45
49
  return;
46
50
  const base = opts.basePath.replace(/\/+$/, '');
47
51
  const promises = [];
48
- // CSS
49
- const cssMode = opts.assets.css === 'auto' ? 'link' : opts.assets.css;
50
- if (cssMode === 'link') {
51
- promises.push(injectTag(doc, 'link', { rel: 'stylesheet', href: `${base}/oat.min.css` }, 'ng-oat-css'));
52
- // Also inject the token layer CSS
53
- promises.push(injectTag(doc, 'link', { rel: 'stylesheet', href: `${base}/tokens.css` }, 'ng-oat-tokens-css'));
52
+ // CSS — only when explicitly opted in
53
+ if (opts.assets.css === 'link') {
54
+ promises.push(injectTag(doc, { rel: 'stylesheet', href: `${base}/oat.min.css` }, 'ng-oat-css'));
55
+ promises.push(injectTag(doc, { rel: 'stylesheet', href: `${base}/tokens.css` }, 'ng-oat-tokens-css'));
54
56
  }
55
- // JS
56
- const jsMode = opts.assets.js === 'auto' ? 'script' : opts.assets.js;
57
- if (jsMode === 'script') {
58
- promises.push(injectTag(doc, 'script', { src: `${base}/oat.min.js`, defer: '' }, 'ng-oat-js'));
59
- }
60
- return Promise.all(promises).then(() => { });
57
+ return promises.length ? Promise.all(promises).then(() => { }) : undefined;
61
58
  }),
62
59
  ]);
63
60
  }
@@ -679,7 +676,7 @@ class NgOatSidebar {
679
676
  this.close();
680
677
  }
681
678
  });
682
- // Listen for toggle clicks within our layout (in case oat.js isn't loaded)
679
+ // Listen for toggle clicks within our layout
683
680
  this.listen(host, 'click', (e) => {
684
681
  const toggle = e.target.closest('[data-sidebar-toggle]');
685
682
  if (toggle) {
@@ -747,10 +744,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
747
744
  }], ctorParameters: () => [], propDecorators: { ngOatSidebarScrollLock: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngOatSidebarScrollLock", required: false }] }], ngOatSidebarChange: [{ type: i0.Output, args: ["ngOatSidebarChange"] }] } });
748
745
 
749
746
  /**
750
- * Angular integration for Oat's <ot-tabs> WebComponent.
747
+ * Angular directive that provides native tab behaviour on any element
748
+ * containing `role="tablist"` > `role="tab"` buttons and sibling
749
+ * `role="tabpanel"` containers.
751
750
  *
752
- * Oat's WC handles all ARIA, keyboard nav, and panel toggling.
753
- * This directive provides Angular-friendly signals and event binding.
751
+ * Works without oat.js — handles tab activation, ARIA attributes,
752
+ * panel visibility and full keyboard navigation internally.
754
753
  *
755
754
  * Usage:
756
755
  * ```html
@@ -775,36 +774,98 @@ class NgOatTabs {
775
774
  activeIndex = signal(0, ...(ngDevMode ? [{ debugName: "activeIndex" }] : []));
776
775
  /** Emits on tab change with { index, tab } */
777
776
  ngOatTabChange = output();
778
- tabChangeListener = null;
777
+ tabEls = [];
778
+ panelEls = [];
779
+ cleanupFns = [];
779
780
  constructor() {
780
781
  afterNextRender(() => {
781
782
  if (!isPlatformBrowser(this.platformId))
782
783
  return;
783
784
  const host = this.el.nativeElement;
784
- // Listen for ot-tab-change custom event emitted by the WebComponent
785
- this.tabChangeListener = (e) => {
786
- const detail = e.detail;
787
- if (detail) {
788
- this.activeIndex.set(detail.index);
789
- this.ngOatTabChange.emit({ index: detail.index, tab: detail.tab });
785
+ // Discover tabs and panels
786
+ const tablist = host.querySelector(':scope > [role="tablist"]');
787
+ this.tabEls = tablist
788
+ ? Array.from(tablist.querySelectorAll('[role="tab"]'))
789
+ : [];
790
+ this.panelEls = Array.from(host.querySelectorAll(':scope > [role="tabpanel"]'));
791
+ // Wire ARIA ids
792
+ this.tabEls.forEach((tab, i) => {
793
+ const panel = this.panelEls[i];
794
+ if (!panel)
795
+ return;
796
+ const tabId = tab.id || `ng-oat-tab-${crypto.randomUUID().slice(0, 8)}`;
797
+ const panelId = panel.id || `ng-oat-panel-${crypto.randomUUID().slice(0, 8)}`;
798
+ tab.id = tabId;
799
+ panel.id = panelId;
800
+ tab.setAttribute('aria-controls', panelId);
801
+ panel.setAttribute('aria-labelledby', tabId);
802
+ });
803
+ // Activate the initial tab
804
+ this.activate(this.activeIndex());
805
+ // Click handler
806
+ const onClick = (e) => {
807
+ const btn = e.target.closest?.('[role="tab"]');
808
+ if (!btn)
809
+ return;
810
+ const idx = this.tabEls.indexOf(btn);
811
+ if (idx >= 0 && !btn.hasAttribute('disabled'))
812
+ this.activate(idx);
813
+ };
814
+ // Keyboard navigation (ArrowLeft / ArrowRight / Home / End)
815
+ const onKeydown = (e) => {
816
+ if (!e.target.closest?.('[role="tab"]'))
817
+ return;
818
+ const len = this.tabEls.length;
819
+ const current = this.activeIndex();
820
+ let next = -1;
821
+ if (e.key === 'ArrowRight')
822
+ next = (current + 1) % len;
823
+ else if (e.key === 'ArrowLeft')
824
+ next = (current - 1 + len) % len;
825
+ else if (e.key === 'Home')
826
+ next = 0;
827
+ else if (e.key === 'End')
828
+ next = len - 1;
829
+ if (next >= 0) {
830
+ e.preventDefault();
831
+ // Skip disabled tabs
832
+ const orig = next;
833
+ const dir = e.key === 'ArrowLeft' || e.key === 'End' ? -1 : 1;
834
+ while (this.tabEls[next]?.hasAttribute('disabled')) {
835
+ next = (next + dir + len) % len;
836
+ if (next === orig)
837
+ return; // all disabled
838
+ }
839
+ this.activate(next);
840
+ this.tabEls[next]?.focus();
790
841
  }
791
842
  };
792
- host.addEventListener('ot-tab-change', this.tabChangeListener);
793
- // Sync initial active index
794
- if (typeof host.activeIndex === 'number') {
795
- this.activeIndex.set(host.activeIndex);
796
- }
843
+ tablist?.addEventListener('click', onClick);
844
+ tablist?.addEventListener('keydown', onKeydown);
845
+ this.cleanupFns.push(() => tablist?.removeEventListener('click', onClick), () => tablist?.removeEventListener('keydown', onKeydown));
797
846
  this.destroyRef.onDestroy(() => {
798
- host.removeEventListener('ot-tab-change', this.tabChangeListener);
847
+ this.cleanupFns.forEach(fn => fn());
848
+ this.cleanupFns = [];
799
849
  });
800
850
  });
801
851
  }
802
852
  /** Programmatically select a tab by index */
803
853
  selectTab(index) {
804
- const host = this.el.nativeElement;
805
- if (typeof host.activeIndex === 'number') {
806
- host.activeIndex = index;
807
- }
854
+ this.activate(index);
855
+ }
856
+ activate(idx) {
857
+ if (idx < 0 || idx >= this.tabEls.length)
858
+ return;
859
+ this.tabEls.forEach((tab, i) => {
860
+ const isActive = i === idx;
861
+ tab.setAttribute('aria-selected', String(isActive));
862
+ tab.tabIndex = isActive ? 0 : -1;
863
+ });
864
+ this.panelEls.forEach((panel, i) => {
865
+ panel.hidden = i !== idx;
866
+ });
867
+ this.activeIndex.set(idx);
868
+ this.ngOatTabChange.emit({ index: idx, tab: this.tabEls[idx] });
808
869
  }
809
870
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatTabs, deps: [], target: i0.ɵɵFactoryTarget.Directive });
810
871
  static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.0", type: NgOatTabs, isStandalone: true, selector: "ot-tabs[ngOatTabs], [ngOatTabs]", outputs: { ngOatTabChange: "ngOatTabChange" }, exportAs: ["ngOatTabs"], ngImport: i0 });
@@ -920,10 +981,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
920
981
  }], ctorParameters: () => [], propDecorators: { ngOatDialogClose: [{ type: i0.Output, args: ["ngOatDialogClose"] }] } });
921
982
 
922
983
  /**
923
- * Angular service wrapping Oat's toast notification system.
984
+ * Angular service for Oat-styled toast notifications.
924
985
  *
925
- * Delegates to `window.ot.toast()` (provided by oat.min.js).
926
- * Falls back to a no-op if Oat JS is not loaded.
986
+ * Fully native implementation no dependency on oat.js.
987
+ * Uses the Oat CSS `.toast`, `.toast-container`, `data-entering/data-exiting`
988
+ * animation patterns, and popover API for stacking.
927
989
  *
928
990
  * Usage:
929
991
  * ```ts
@@ -937,11 +999,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
937
999
  class NgOatToast {
938
1000
  platformId = inject(PLATFORM_ID);
939
1001
  doc = inject(DOCUMENT);
940
- get ot() {
941
- if (!isPlatformBrowser(this.platformId))
942
- return null;
943
- return this.doc.defaultView?.ot ?? null;
944
- }
1002
+ /** Cache of toast containers keyed by placement */
1003
+ containers = new Map();
1004
+ // ── Convenience methods ─────────────────────────────────────────────
945
1005
  success(message, title, options) {
946
1006
  this.show(message, title, { ...options, variant: 'success' });
947
1007
  }
@@ -954,44 +1014,62 @@ class NgOatToast {
954
1014
  error(message, title, options) {
955
1015
  this.show(message, title, { ...options, variant: 'danger' });
956
1016
  }
1017
+ // ── Core show method ────────────────────────────────────────────────
957
1018
  show(message, title, options = {}) {
958
- const ot = this.ot;
959
- if (!ot?.toast) {
960
- console.warn('ng-oat: Oat JS not loaded. Toast not shown. Ensure oat.min.js is loaded via provideNgOat() or angular.json scripts.');
1019
+ if (!isPlatformBrowser(this.platformId))
961
1020
  return;
1021
+ const { variant = 'info', dismissible, ...rest } = options;
1022
+ const el = this.doc.createElement('output');
1023
+ el.setAttribute('data-variant', variant);
1024
+ if (title) {
1025
+ const titleEl = this.doc.createElement('h6');
1026
+ titleEl.className = 'toast-title';
1027
+ titleEl.textContent = title;
1028
+ el.appendChild(titleEl);
962
1029
  }
963
- const { dismissible, ...rest } = options;
964
- const el = ot.toast(message, title, rest);
965
- if (dismissible && el) {
1030
+ const msgEl = this.doc.createElement('div');
1031
+ msgEl.className = 'toast-message';
1032
+ msgEl.textContent = message;
1033
+ el.appendChild(msgEl);
1034
+ if (dismissible) {
966
1035
  this.addCloseButton(el);
967
1036
  }
968
- return el;
1037
+ return this.showEl(el, rest);
969
1038
  }
970
1039
  /**
971
1040
  * Show a toast from a DOM element or template element.
972
- * For Angular TemplateRef, use showTemplate() instead.
973
1041
  */
974
1042
  showElement(element, options = {}) {
975
- const ot = this.ot;
976
- if (!ot?.toast?.el)
1043
+ if (!isPlatformBrowser(this.platformId))
977
1044
  return;
978
- ot.toast.el(element, options);
1045
+ let target;
1046
+ if (element instanceof HTMLTemplateElement) {
1047
+ target = element.content.firstElementChild?.cloneNode(true);
1048
+ }
1049
+ else {
1050
+ target = element.cloneNode(true);
1051
+ }
1052
+ if (!target)
1053
+ return;
1054
+ target.removeAttribute('id');
1055
+ this.showEl(target, options);
979
1056
  }
980
1057
  /**
981
1058
  * Show a toast from an Angular TemplateRef.
982
1059
  * Requires a ViewContainerRef to render the template.
983
1060
  */
984
1061
  showTemplate(templateRef, vcr, options = {}) {
985
- const ot = this.ot;
986
- if (!ot?.toast?.el)
1062
+ if (!isPlatformBrowser(this.platformId))
987
1063
  return;
988
1064
  const view = vcr.createEmbeddedView(templateRef);
989
1065
  view.detectChanges();
990
- // Wrap template nodes in a container
991
1066
  const container = this.doc.createElement('div');
992
1067
  view.rootNodes.forEach((node) => container.appendChild(node));
993
- ot.toast.el(container, options);
994
- // Clean up the view after the toast duration
1068
+ const { dismissible, ...rest } = options;
1069
+ if (dismissible) {
1070
+ this.addCloseButton(container);
1071
+ }
1072
+ this.showEl(container, rest);
995
1073
  const duration = options.duration ?? 4000;
996
1074
  if (duration > 0) {
997
1075
  setTimeout(() => view.destroy(), duration + 500);
@@ -999,7 +1077,82 @@ class NgOatToast {
999
1077
  }
1000
1078
  /** Dismiss toasts. If placement given, only that position; otherwise all. */
1001
1079
  dismiss(placement) {
1002
- this.ot?.toast?.clear(placement);
1080
+ if (!isPlatformBrowser(this.platformId))
1081
+ return;
1082
+ if (placement) {
1083
+ const c = this.containers.get(placement);
1084
+ if (c) {
1085
+ c.innerHTML = '';
1086
+ c.hidePopover();
1087
+ }
1088
+ }
1089
+ else {
1090
+ this.containers.forEach(c => {
1091
+ c.innerHTML = '';
1092
+ c.hidePopover();
1093
+ });
1094
+ }
1095
+ }
1096
+ // ── Internal helpers ────────────────────────────────────────────────
1097
+ /** Get or create a toast container for the given placement */
1098
+ getContainer(placement) {
1099
+ let c = this.containers.get(placement);
1100
+ if (!c) {
1101
+ c = this.doc.createElement('div');
1102
+ c.className = 'toast-container';
1103
+ c.setAttribute('popover', 'manual');
1104
+ c.setAttribute('data-placement', placement);
1105
+ this.doc.body.appendChild(c);
1106
+ this.containers.set(placement, c);
1107
+ }
1108
+ return c;
1109
+ }
1110
+ /** Show a prepared element as a toast */
1111
+ showEl(el, options = {}) {
1112
+ const { placement = 'top-right', duration = 4000 } = options;
1113
+ const container = this.getContainer(placement);
1114
+ el.classList.add('toast');
1115
+ let timeout;
1116
+ // Pause auto-dismiss on hover
1117
+ el.onmouseenter = () => clearTimeout(timeout);
1118
+ el.onmouseleave = () => {
1119
+ if (duration > 0) {
1120
+ timeout = setTimeout(() => this.removeToast(el, container), duration);
1121
+ }
1122
+ };
1123
+ // Show with enter animation
1124
+ el.setAttribute('data-entering', '');
1125
+ container.appendChild(el);
1126
+ container.showPopover();
1127
+ // Double rAF so computed styles apply before transition starts
1128
+ requestAnimationFrame(() => {
1129
+ requestAnimationFrame(() => {
1130
+ el.removeAttribute('data-entering');
1131
+ });
1132
+ });
1133
+ // Auto-dismiss
1134
+ if (duration > 0) {
1135
+ timeout = setTimeout(() => this.removeToast(el, container), duration);
1136
+ }
1137
+ return el;
1138
+ }
1139
+ /** Remove a toast with exit animation */
1140
+ removeToast(el, container) {
1141
+ if (el.hasAttribute('data-exiting'))
1142
+ return;
1143
+ el.setAttribute('data-exiting', '');
1144
+ const cleanup = () => {
1145
+ el.remove();
1146
+ if (!container.children.length) {
1147
+ container.hidePopover();
1148
+ }
1149
+ };
1150
+ el.addEventListener('transitionend', cleanup, { once: true });
1151
+ // Fallback timeout for clients that disable animations
1152
+ const t = getComputedStyle(el).getPropertyValue('--transition').trim();
1153
+ const val = parseFloat(t) || 300;
1154
+ const ms = t.endsWith('ms') ? val : val * 1000;
1155
+ setTimeout(cleanup, ms > 0 ? ms : 350);
1003
1156
  }
1004
1157
  /** Add a close button to a toast element */
1005
1158
  addCloseButton(el) {
@@ -1011,10 +1164,8 @@ class NgOatToast {
1011
1164
  btn.addEventListener('mouseenter', () => { btn.style.opacity = '1'; });
1012
1165
  btn.addEventListener('mouseleave', () => { btn.style.opacity = '0.6'; });
1013
1166
  btn.addEventListener('click', () => {
1014
- el.setAttribute('data-exiting', '');
1015
- const cleanup = () => el.remove();
1016
- el.addEventListener('transitionend', cleanup, { once: true });
1017
- setTimeout(cleanup, 350);
1167
+ const container = el.parentElement;
1168
+ this.removeToast(el, container);
1018
1169
  });
1019
1170
  el.style.position = 'relative';
1020
1171
  el.style.paddingRight = '2rem';
@@ -1973,17 +2124,21 @@ class NgOatDropdownComponent {
1973
2124
  const items = host.querySelectorAll('[role="menuitem"]');
1974
2125
  items[0]?.focus();
1975
2126
  this.triggerEl?.setAttribute('aria-expanded', 'true');
2127
+ // Add keyboard navigation
2128
+ this.popoverEl?.addEventListener('keydown', this.onKeydown);
1976
2129
  }
1977
2130
  else {
1978
2131
  window.removeEventListener('scroll', this.positionFn, true);
1979
2132
  window.removeEventListener('resize', this.positionFn);
1980
2133
  this.triggerEl?.setAttribute('aria-expanded', 'false');
1981
2134
  this.triggerEl?.focus();
2135
+ this.popoverEl?.removeEventListener('keydown', this.onKeydown);
1982
2136
  }
1983
2137
  };
1984
2138
  this.popoverEl.addEventListener('toggle', onToggle);
1985
2139
  this.destroyRef.onDestroy(() => {
1986
2140
  this.popoverEl?.removeEventListener('toggle', onToggle);
2141
+ this.popoverEl?.removeEventListener('keydown', this.onKeydown);
1987
2142
  window.removeEventListener('scroll', this.positionFn, true);
1988
2143
  window.removeEventListener('resize', this.positionFn);
1989
2144
  });
@@ -1998,6 +2153,44 @@ class NgOatDropdownComponent {
1998
2153
  toggle() {
1999
2154
  this.popoverEl?.togglePopover?.();
2000
2155
  }
2156
+ /** Keyboard navigation for menu items (ArrowDown/Up/Home/End/Escape) */
2157
+ onKeydown = (e) => {
2158
+ const host = this.elRef.nativeElement;
2159
+ const items = Array.from(host.querySelectorAll('[role="menuitem"]:not([disabled])'));
2160
+ if (!items.length)
2161
+ return;
2162
+ const current = items.indexOf(this.doc.activeElement);
2163
+ switch (e.key) {
2164
+ case 'ArrowDown': {
2165
+ e.preventDefault();
2166
+ const next = current < items.length - 1 ? current + 1 : 0;
2167
+ items[next]?.focus();
2168
+ break;
2169
+ }
2170
+ case 'ArrowUp': {
2171
+ e.preventDefault();
2172
+ const prev = current > 0 ? current - 1 : items.length - 1;
2173
+ items[prev]?.focus();
2174
+ break;
2175
+ }
2176
+ case 'Home': {
2177
+ e.preventDefault();
2178
+ items[0]?.focus();
2179
+ break;
2180
+ }
2181
+ case 'End': {
2182
+ e.preventDefault();
2183
+ items[items.length - 1]?.focus();
2184
+ break;
2185
+ }
2186
+ case 'Escape': {
2187
+ e.preventDefault();
2188
+ this.close();
2189
+ break;
2190
+ }
2191
+ }
2192
+ };
2193
+ doc = inject(DOCUMENT);
2001
2194
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatDropdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2002
2195
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.0", type: NgOatDropdownComponent, isStandalone: true, selector: "ng-oat-dropdown", outputs: { openChange: "openChange" }, ngImport: i0, template: `
2003
2196
  <ot-dropdown>
@@ -4324,6 +4517,455 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
4324
4517
  `, styles: [":host{display:block}\n"] }]
4325
4518
  }], propDecorators: { heading: [{ type: i0.Input, args: [{ isSignal: true, alias: "heading", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], showSeeAll: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSeeAll", required: false }] }], scrollAmount: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollAmount", required: false }] }], seeAllClick: [{ type: i0.Output, args: ["seeAllClick"] }], cardClick: [{ type: i0.Output, args: ["cardClick"] }], trackRef: [{ type: i0.ViewChild, args: ['track', { isSignal: true }] }] } });
4326
4519
 
4520
+ /**
4521
+ * Angular toolbar component — like mat-toolbar, built on Oat CSS.
4522
+ *
4523
+ * Renders a fixed-position `<nav data-topnav>` that leverages Oat's built-in
4524
+ * topnav styling (flex, border, shadow). Content-projected slots let you
4525
+ * arrange Logo / nav-links / actions however you like.
4526
+ *
4527
+ * ## Slots
4528
+ * - **`[toolbarStart]`** — Left-aligned content (logo, brand, hamburger)
4529
+ * - **Default `<ng-content>`** — Center / free-form content (nav links, search)
4530
+ * - **`[toolbarEnd]`** — Right-aligned content (user menu, theme toggle, actions)
4531
+ *
4532
+ * ## Layout
4533
+ * The toolbar uses `display:flex; align-items:center` with a spacer between
4534
+ * the default content and the end slot, so start items anchor left and end
4535
+ * items anchor right automatically.
4536
+ *
4537
+ * Usage:
4538
+ * ```html
4539
+ * <ng-oat-toolbar>
4540
+ * <a toolbarStart routerLink="/" class="brand">🌾 MyApp</a>
4541
+ * <nav>
4542
+ * <a routerLink="/home">Home</a>
4543
+ * <a routerLink="/about">About</a>
4544
+ * </nav>
4545
+ * <ng-oat-dropdown toolbarEnd>
4546
+ * <button trigger class="ghost">👤 User ▾</button>
4547
+ * <a role="menuitem">Profile</a>
4548
+ * <a role="menuitem">Settings</a>
4549
+ * <hr />
4550
+ * <a role="menuitem">Logout</a>
4551
+ * </ng-oat-dropdown>
4552
+ * </ng-oat-toolbar>
4553
+ * ```
4554
+ */
4555
+ class NgOatToolbar {
4556
+ color = input('default', ...(ngDevMode ? [{ debugName: "color" }] : []));
4557
+ dense = input(false, ...(ngDevMode ? [{ debugName: "dense" }] : []));
4558
+ fixed = input(true, ...(ngDevMode ? [{ debugName: "fixed" }] : []));
4559
+ attrVariant = computed(() => {
4560
+ const c = this.color();
4561
+ return c === 'default' ? null : c;
4562
+ }, ...(ngDevMode ? [{ debugName: "attrVariant" }] : []));
4563
+ navClass = computed(() => {
4564
+ const classes = [];
4565
+ const c = this.color();
4566
+ if (c !== 'default')
4567
+ classes.push(`oat-toolbar-${c}`);
4568
+ if (this.dense())
4569
+ classes.push('oat-toolbar-dense');
4570
+ if (!this.fixed())
4571
+ classes.push('oat-toolbar-static');
4572
+ return classes.join(' ');
4573
+ }, ...(ngDevMode ? [{ debugName: "navClass" }] : []));
4574
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatToolbar, deps: [], target: i0.ɵɵFactoryTarget.Component });
4575
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.0", type: NgOatToolbar, isStandalone: true, selector: "ng-oat-toolbar", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, dense: { classPropertyName: "dense", publicName: "dense", isSignal: true, isRequired: false, transformFunction: null }, fixed: { classPropertyName: "fixed", publicName: "fixed", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "toolbar" }, properties: { "attr.data-variant": "attrVariant()" } }, ngImport: i0, template: `
4576
+ <nav data-topnav [class]="navClass()">
4577
+ <ng-content select="[toolbarStart]" />
4578
+ <ng-content />
4579
+ <span class="oat-toolbar-spacer"></span>
4580
+ <ng-content select="[toolbarEnd]" />
4581
+ </nav>
4582
+ `, isInline: true, styles: [":host{display:block}.oat-toolbar-spacer{flex:1 1 auto}nav[data-topnav].oat-toolbar-static{position:relative;inset:unset;z-index:auto}nav[data-topnav].oat-toolbar-dense{min-height:var(--space-10);padding-block:var(--space-1)}nav[data-topnav].oat-toolbar-primary{background-color:var(--primary);color:var(--primary-foreground);border-bottom-color:var(--primary)}nav[data-topnav].oat-toolbar-primary a{color:var(--primary-foreground)}nav[data-topnav].oat-toolbar-accent{background-color:var(--accent);color:var(--foreground);border-bottom-color:var(--accent)}\n"] });
4583
+ }
4584
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatToolbar, decorators: [{
4585
+ type: Component,
4586
+ args: [{ selector: 'ng-oat-toolbar', host: {
4587
+ 'role': 'toolbar',
4588
+ '[attr.data-variant]': 'attrVariant()',
4589
+ }, template: `
4590
+ <nav data-topnav [class]="navClass()">
4591
+ <ng-content select="[toolbarStart]" />
4592
+ <ng-content />
4593
+ <span class="oat-toolbar-spacer"></span>
4594
+ <ng-content select="[toolbarEnd]" />
4595
+ </nav>
4596
+ `, styles: [":host{display:block}.oat-toolbar-spacer{flex:1 1 auto}nav[data-topnav].oat-toolbar-static{position:relative;inset:unset;z-index:auto}nav[data-topnav].oat-toolbar-dense{min-height:var(--space-10);padding-block:var(--space-1)}nav[data-topnav].oat-toolbar-primary{background-color:var(--primary);color:var(--primary-foreground);border-bottom-color:var(--primary)}nav[data-topnav].oat-toolbar-primary a{color:var(--primary-foreground)}nav[data-topnav].oat-toolbar-accent{background-color:var(--accent);color:var(--foreground);border-bottom-color:var(--accent)}\n"] }]
4597
+ }], propDecorators: { color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], dense: [{ type: i0.Input, args: [{ isSignal: true, alias: "dense", required: false }] }], fixed: [{ type: i0.Input, args: [{ isSignal: true, alias: "fixed", required: false }] }] } });
4598
+ /**
4599
+ * Toolbar row — use multiple rows stacked inside a toolbar.
4600
+ *
4601
+ * Usage:
4602
+ * ```html
4603
+ * <ng-oat-toolbar>
4604
+ * <ng-oat-toolbar-row>
4605
+ * <a toolbarStart>Brand</a>
4606
+ * <span toolbarEnd>Actions</span>
4607
+ * </ng-oat-toolbar-row>
4608
+ * <ng-oat-toolbar-row dense>
4609
+ * <nav>Sub-navigation tabs</nav>
4610
+ * </ng-oat-toolbar-row>
4611
+ * </ng-oat-toolbar>
4612
+ * ```
4613
+ */
4614
+ class NgOatToolbarRow {
4615
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatToolbarRow, deps: [], target: i0.ɵɵFactoryTarget.Component });
4616
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.0", type: NgOatToolbarRow, isStandalone: true, selector: "ng-oat-toolbar-row", host: { classAttribute: "oat-toolbar-row" }, ngImport: i0, template: `<ng-content />`, isInline: true, styles: [":host{display:flex;align-items:center;gap:var(--space-3);width:100%}\n"] });
4617
+ }
4618
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatToolbarRow, decorators: [{
4619
+ type: Component,
4620
+ args: [{ selector: 'ng-oat-toolbar-row', host: { class: 'oat-toolbar-row' }, template: `<ng-content />`, styles: [":host{display:flex;align-items:center;gap:var(--space-3);width:100%}\n"] }]
4621
+ }] });
4622
+
4623
+ const DEFAULT_THEMES = [
4624
+ { value: 'light', label: 'Light' },
4625
+ { value: 'dark', label: 'Dark' },
4626
+ { value: 'system', label: 'System' },
4627
+ ];
4628
+ const STORAGE_KEY = 'ng-oat-theme';
4629
+ /**
4630
+ * Structural directive for fully-custom icon rendering.
4631
+ *
4632
+ * Place inside `<ng-oat-theme-selector>` to replace the built-in SVG icons.
4633
+ * The template context receives the current `NgOatThemeOption` as `$implicit`.
4634
+ *
4635
+ * ```html
4636
+ * <ng-oat-theme-selector>
4637
+ * <ng-template ngOatThemeSelectorIcon let-opt>
4638
+ * <my-icon [name]="opt.value" />
4639
+ * </ng-template>
4640
+ * </ng-oat-theme-selector>
4641
+ * ```
4642
+ */
4643
+ class NgOatThemeSelectorIcon {
4644
+ tpl = inject(TemplateRef);
4645
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatThemeSelectorIcon, deps: [], target: i0.ɵɵFactoryTarget.Directive });
4646
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.0", type: NgOatThemeSelectorIcon, isStandalone: true, selector: "ng-template[ngOatThemeSelectorIcon]", ngImport: i0 });
4647
+ }
4648
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatThemeSelectorIcon, decorators: [{
4649
+ type: Directive,
4650
+ args: [{ selector: 'ng-template[ngOatThemeSelectorIcon]' }]
4651
+ }] });
4652
+ /**
4653
+ * Theme selector — shadcn-inspired light / dark / system toggle.
4654
+ *
4655
+ * Applies `colorScheme` on `<html>` and persists the choice to `localStorage`.
4656
+ * When "system" is selected it respects `prefers-color-scheme`.
4657
+ *
4658
+ * Usage:
4659
+ * ```html
4660
+ * <!-- Dropdown-style (default) -->
4661
+ * <ng-oat-theme-selector />
4662
+ *
4663
+ * <!-- Inline toggle group style -->
4664
+ * <ng-oat-theme-selector mode="toggle" />
4665
+ *
4666
+ * <!-- Listen for changes -->
4667
+ * <ng-oat-theme-selector (themeChange)="onTheme($event)" />
4668
+ * ```
4669
+ */
4670
+ class NgOatThemeSelector {
4671
+ /** Display mode: dropdown menu or inline toggle group */
4672
+ mode = input('dropdown', ...(ngDevMode ? [{ debugName: "mode" }] : []));
4673
+ /** Initial theme (overrides localStorage if set) */
4674
+ initialTheme = input(undefined, ...(ngDevMode ? [{ debugName: "initialTheme" }] : []));
4675
+ /**
4676
+ * Custom theme options. Override labels, provide emoji icons, or
4677
+ * change the set entirely.
4678
+ *
4679
+ * ```html
4680
+ * <ng-oat-theme-selector
4681
+ * [themes]="[
4682
+ * { value: 'light', label: 'Day', icon: '🌅' },
4683
+ * { value: 'dark', label: 'Night', icon: '🌃' },
4684
+ * { value: 'system', label: 'Auto', icon: '🖥️' },
4685
+ * ]" />
4686
+ * ```
4687
+ */
4688
+ themes = input(DEFAULT_THEMES, ...(ngDevMode ? [{ debugName: "themes" }] : []));
4689
+ /** Emits when the user picks a theme */
4690
+ themeChange = output();
4691
+ /** Current active theme */
4692
+ current = signal('system', ...(ngDevMode ? [{ debugName: "current" }] : []));
4693
+ open = signal(false, ...(ngDevMode ? [{ debugName: "open" }] : []));
4694
+ /** Content-projected custom icon template */
4695
+ iconTpl = contentChild(NgOatThemeSelectorIcon, ...(ngDevMode ? [{ debugName: "iconTpl" }] : []));
4696
+ /** Resolved themes (input or defaults) */
4697
+ resolvedThemes = computed(() => this.themes(), ...(ngDevMode ? [{ debugName: "resolvedThemes" }] : []));
4698
+ /** The currently active option object */
4699
+ activeOption = computed(() => this.resolvedThemes().find(t => t.value === this.current()) ?? this.resolvedThemes()[0], ...(ngDevMode ? [{ debugName: "activeOption" }] : []));
4700
+ doc = inject(DOCUMENT);
4701
+ platformId = inject(PLATFORM_ID);
4702
+ mediaQuery = null;
4703
+ mediaListener = (e) => this.applyResolved(e.matches ? 'dark' : 'light');
4704
+ /** Toggle the dropdown open/closed */
4705
+ toggleOpen() {
4706
+ this.open.update(v => !v);
4707
+ }
4708
+ /** Pick a theme and close the dropdown */
4709
+ pick(theme) {
4710
+ this.setTheme(theme);
4711
+ this.open.set(false);
4712
+ }
4713
+ /** Keyboard handler for Escape and arrow-key navigation inside the menu */
4714
+ onHostKey(e) {
4715
+ if (e.key === 'Escape' && this.open()) {
4716
+ e.preventDefault();
4717
+ this.open.set(false);
4718
+ // Return focus to trigger
4719
+ const trigger = e.target?.closest('.oat-ts-dropdown')?.querySelector('.oat-ts-trigger');
4720
+ trigger?.focus();
4721
+ return;
4722
+ }
4723
+ if (!this.open())
4724
+ return;
4725
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
4726
+ e.preventDefault();
4727
+ const menu = e.target?.closest('.oat-ts-dropdown')?.querySelector('.oat-ts-menu');
4728
+ if (!menu)
4729
+ return;
4730
+ const items = Array.from(menu.querySelectorAll('button[role="option"]'));
4731
+ const idx = items.indexOf(e.target);
4732
+ const next = e.key === 'ArrowDown'
4733
+ ? items[(idx + 1) % items.length]
4734
+ : items[(idx - 1 + items.length) % items.length];
4735
+ next?.focus();
4736
+ }
4737
+ }
4738
+ ngOnInit() {
4739
+ if (!isPlatformBrowser(this.platformId))
4740
+ return;
4741
+ this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
4742
+ // Determine starting theme: explicit input > localStorage > system
4743
+ const init = this.initialTheme();
4744
+ if (init) {
4745
+ this.setTheme(init);
4746
+ }
4747
+ else {
4748
+ const stored = localStorage.getItem(STORAGE_KEY);
4749
+ this.setTheme(stored ?? 'system');
4750
+ }
4751
+ // Close dropdown on outside click
4752
+ this.doc.addEventListener('click', this.onDocClick);
4753
+ }
4754
+ setTheme(theme) {
4755
+ this.current.set(theme);
4756
+ this.themeChange.emit(theme);
4757
+ if (!isPlatformBrowser(this.platformId))
4758
+ return;
4759
+ localStorage.setItem(STORAGE_KEY, theme);
4760
+ // Unsubscribe from previous media listener
4761
+ this.mediaQuery?.removeEventListener('change', this.mediaListener);
4762
+ if (theme === 'system') {
4763
+ this.applyResolved(this.mediaQuery?.matches ? 'dark' : 'light');
4764
+ this.mediaQuery?.addEventListener('change', this.mediaListener);
4765
+ }
4766
+ else {
4767
+ this.applyResolved(theme);
4768
+ }
4769
+ }
4770
+ applyResolved(resolved) {
4771
+ this.doc.documentElement.style.colorScheme = resolved;
4772
+ }
4773
+ onDocClick = (e) => {
4774
+ if (!e.target?.closest?.('.oat-ts-dropdown')) {
4775
+ this.open.set(false);
4776
+ }
4777
+ };
4778
+ /** @internal */
4779
+ ngOnDestroy() {
4780
+ this.mediaQuery?.removeEventListener('change', this.mediaListener);
4781
+ if (isPlatformBrowser(this.platformId)) {
4782
+ this.doc.removeEventListener('click', this.onDocClick);
4783
+ }
4784
+ }
4785
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatThemeSelector, deps: [], target: i0.ɵɵFactoryTarget.Component });
4786
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: NgOatThemeSelector, isStandalone: true, selector: "ng-oat-theme-selector", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, initialTheme: { classPropertyName: "initialTheme", publicName: "initialTheme", isSignal: true, isRequired: false, transformFunction: null }, themes: { classPropertyName: "themes", publicName: "themes", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { themeChange: "themeChange" }, host: { listeners: { "keydown": "onHostKey($event)" }, properties: { "attr.data-mode": "mode()" }, classAttribute: "oat-theme-selector" }, queries: [{ propertyName: "iconTpl", first: true, predicate: NgOatThemeSelectorIcon, descendants: true, isSignal: true }], ngImport: i0, template: `
4787
+ @if (mode() === 'dropdown') {
4788
+ <div class="oat-ts-dropdown">
4789
+ <button
4790
+ class="oat-ts-trigger"
4791
+ type="button"
4792
+ aria-haspopup="listbox"
4793
+ [attr.aria-expanded]="open()"
4794
+ aria-label="Theme: {{ current() }}"
4795
+ (click)="toggleOpen()">
4796
+ <ng-container *ngTemplateOutlet="iconTpl()?.tpl ?? null; context: { $implicit: activeOption() }" />
4797
+ @if (!iconTpl()) {
4798
+ @if (activeOption().icon) {
4799
+ <span class="oat-ts-emoji" aria-hidden="true">{{ activeOption().icon }}</span>
4800
+ } @else {
4801
+ <ng-container [ngSwitch]="current()">
4802
+ <svg *ngSwitchCase="'light'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
4803
+ <svg *ngSwitchCase="'dark'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
4804
+ <svg *ngSwitchDefault class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
4805
+ </ng-container>
4806
+ }
4807
+ }
4808
+ <svg class="oat-ts-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor"
4809
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4810
+ <polyline points="6 9 12 15 18 9"/>
4811
+ </svg>
4812
+ </button>
4813
+ @if (open()) {
4814
+ <div class="oat-ts-menu" role="listbox" aria-label="Select theme">
4815
+ @for (t of resolvedThemes(); track t.value) {
4816
+ <button
4817
+ role="option"
4818
+ type="button"
4819
+ [attr.aria-selected]="current() === t.value"
4820
+ [class.active]="current() === t.value"
4821
+ (click)="pick(t.value)">
4822
+ <ng-container *ngTemplateOutlet="iconTpl()?.tpl ?? null; context: { $implicit: t }" />
4823
+ @if (!iconTpl()) {
4824
+ @if (t.icon) {
4825
+ <span class="oat-ts-emoji" aria-hidden="true">{{ t.icon }}</span>
4826
+ } @else {
4827
+ <ng-container [ngSwitch]="t.value">
4828
+ <svg *ngSwitchCase="'light'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
4829
+ <svg *ngSwitchCase="'dark'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
4830
+ <svg *ngSwitchDefault class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
4831
+ </ng-container>
4832
+ }
4833
+ }
4834
+ <span>{{ t.label }}</span>
4835
+ @if (current() === t.value) {
4836
+ <svg class="oat-ts-check" viewBox="0 0 24 24" fill="none" stroke="currentColor"
4837
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4838
+ <polyline points="20 6 9 17 4 12"/>
4839
+ </svg>
4840
+ }
4841
+ </button>
4842
+ }
4843
+ </div>
4844
+ }
4845
+ </div>
4846
+ } @else {
4847
+ <div class="oat-ts-toggle" role="radiogroup" aria-label="Theme">
4848
+ @for (t of resolvedThemes(); track t.value) {
4849
+ <button
4850
+ type="button"
4851
+ role="radio"
4852
+ [attr.aria-checked]="current() === t.value"
4853
+ [attr.aria-label]="t.label"
4854
+ [class.active]="current() === t.value"
4855
+ (click)="setTheme(t.value)">
4856
+ <ng-container *ngTemplateOutlet="iconTpl()?.tpl ?? null; context: { $implicit: t }" />
4857
+ @if (!iconTpl()) {
4858
+ @if (t.icon) {
4859
+ <span class="oat-ts-emoji" aria-hidden="true">{{ t.icon }}</span>
4860
+ } @else {
4861
+ <ng-container [ngSwitch]="t.value">
4862
+ <svg *ngSwitchCase="'light'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
4863
+ <svg *ngSwitchCase="'dark'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
4864
+ <svg *ngSwitchDefault class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
4865
+ </ng-container>
4866
+ }
4867
+ }
4868
+ </button>
4869
+ }
4870
+ </div>
4871
+ }
4872
+ `, isInline: true, styles: [":host{display:inline-flex}.oat-ts-svg{width:1rem;height:1rem;flex-shrink:0}.oat-ts-emoji{font-size:1rem;line-height:1;flex-shrink:0}.oat-ts-dropdown{position:relative}.oat-ts-trigger{display:inline-flex;align-items:center;gap:var(--space-1);cursor:pointer;padding:var(--space-1) var(--space-2);border-radius:var(--radius-medium);border:1px solid var(--border);background:transparent;color:var(--foreground);transition:background .15s,border-color .15s;line-height:1}.oat-ts-trigger:hover{background:var(--accent)}.oat-ts-trigger:focus-visible{outline:2px solid var(--ring);outline-offset:2px}.oat-ts-chevron{width:.75rem;height:.75rem;opacity:.5}.oat-ts-menu{position:absolute;top:calc(100% + var(--space-1));right:0;z-index:50;min-width:8rem;border:1px solid var(--border);border-radius:var(--radius-medium);background:var(--background);box-shadow:var(--shadow-small);padding:var(--space-1);display:flex;flex-direction:column;animation:oat-ts-fade-in .12s ease-out}@keyframes oat-ts-fade-in{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.oat-ts-menu button{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-2);border-radius:var(--radius-small);cursor:pointer;font-size:var(--text-6);line-height:1.25;color:var(--foreground);transition:background .1s}.oat-ts-menu button:hover,.oat-ts-menu button:focus-visible{background:var(--accent)}.oat-ts-menu button:focus-visible{outline:2px solid var(--ring);outline-offset:-2px}.oat-ts-menu button.active{font-weight:500}.oat-ts-check{width:.875rem;height:.875rem;margin-inline-start:auto;opacity:.7}.oat-ts-toggle{display:inline-flex;border:1px solid var(--border);border-radius:var(--radius-medium);overflow:hidden}.oat-ts-toggle button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;padding:var(--space-1) var(--space-2);cursor:pointer;transition:background .15s;color:var(--muted-foreground, var(--foreground))}.oat-ts-toggle button:hover{background:var(--accent)}.oat-ts-toggle button:focus-visible{outline:2px solid var(--ring);outline-offset:-2px}.oat-ts-toggle button.active{background:var(--accent);color:var(--foreground)}.oat-ts-toggle button+button{border-left:1px solid var(--border)}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { kind: "directive", type: NgSwitchDefault, selector: "[ngSwitchDefault]" }] });
4873
+ }
4874
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: NgOatThemeSelector, decorators: [{
4875
+ type: Component,
4876
+ args: [{ selector: 'ng-oat-theme-selector', imports: [NgTemplateOutlet, NgSwitch, NgSwitchCase, NgSwitchDefault], host: {
4877
+ 'class': 'oat-theme-selector',
4878
+ '[attr.data-mode]': 'mode()',
4879
+ '(keydown)': 'onHostKey($event)',
4880
+ }, template: `
4881
+ @if (mode() === 'dropdown') {
4882
+ <div class="oat-ts-dropdown">
4883
+ <button
4884
+ class="oat-ts-trigger"
4885
+ type="button"
4886
+ aria-haspopup="listbox"
4887
+ [attr.aria-expanded]="open()"
4888
+ aria-label="Theme: {{ current() }}"
4889
+ (click)="toggleOpen()">
4890
+ <ng-container *ngTemplateOutlet="iconTpl()?.tpl ?? null; context: { $implicit: activeOption() }" />
4891
+ @if (!iconTpl()) {
4892
+ @if (activeOption().icon) {
4893
+ <span class="oat-ts-emoji" aria-hidden="true">{{ activeOption().icon }}</span>
4894
+ } @else {
4895
+ <ng-container [ngSwitch]="current()">
4896
+ <svg *ngSwitchCase="'light'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
4897
+ <svg *ngSwitchCase="'dark'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
4898
+ <svg *ngSwitchDefault class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
4899
+ </ng-container>
4900
+ }
4901
+ }
4902
+ <svg class="oat-ts-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor"
4903
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4904
+ <polyline points="6 9 12 15 18 9"/>
4905
+ </svg>
4906
+ </button>
4907
+ @if (open()) {
4908
+ <div class="oat-ts-menu" role="listbox" aria-label="Select theme">
4909
+ @for (t of resolvedThemes(); track t.value) {
4910
+ <button
4911
+ role="option"
4912
+ type="button"
4913
+ [attr.aria-selected]="current() === t.value"
4914
+ [class.active]="current() === t.value"
4915
+ (click)="pick(t.value)">
4916
+ <ng-container *ngTemplateOutlet="iconTpl()?.tpl ?? null; context: { $implicit: t }" />
4917
+ @if (!iconTpl()) {
4918
+ @if (t.icon) {
4919
+ <span class="oat-ts-emoji" aria-hidden="true">{{ t.icon }}</span>
4920
+ } @else {
4921
+ <ng-container [ngSwitch]="t.value">
4922
+ <svg *ngSwitchCase="'light'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
4923
+ <svg *ngSwitchCase="'dark'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
4924
+ <svg *ngSwitchDefault class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
4925
+ </ng-container>
4926
+ }
4927
+ }
4928
+ <span>{{ t.label }}</span>
4929
+ @if (current() === t.value) {
4930
+ <svg class="oat-ts-check" viewBox="0 0 24 24" fill="none" stroke="currentColor"
4931
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4932
+ <polyline points="20 6 9 17 4 12"/>
4933
+ </svg>
4934
+ }
4935
+ </button>
4936
+ }
4937
+ </div>
4938
+ }
4939
+ </div>
4940
+ } @else {
4941
+ <div class="oat-ts-toggle" role="radiogroup" aria-label="Theme">
4942
+ @for (t of resolvedThemes(); track t.value) {
4943
+ <button
4944
+ type="button"
4945
+ role="radio"
4946
+ [attr.aria-checked]="current() === t.value"
4947
+ [attr.aria-label]="t.label"
4948
+ [class.active]="current() === t.value"
4949
+ (click)="setTheme(t.value)">
4950
+ <ng-container *ngTemplateOutlet="iconTpl()?.tpl ?? null; context: { $implicit: t }" />
4951
+ @if (!iconTpl()) {
4952
+ @if (t.icon) {
4953
+ <span class="oat-ts-emoji" aria-hidden="true">{{ t.icon }}</span>
4954
+ } @else {
4955
+ <ng-container [ngSwitch]="t.value">
4956
+ <svg *ngSwitchCase="'light'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
4957
+ <svg *ngSwitchCase="'dark'" class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
4958
+ <svg *ngSwitchDefault class="oat-ts-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
4959
+ </ng-container>
4960
+ }
4961
+ }
4962
+ </button>
4963
+ }
4964
+ </div>
4965
+ }
4966
+ `, styles: [":host{display:inline-flex}.oat-ts-svg{width:1rem;height:1rem;flex-shrink:0}.oat-ts-emoji{font-size:1rem;line-height:1;flex-shrink:0}.oat-ts-dropdown{position:relative}.oat-ts-trigger{display:inline-flex;align-items:center;gap:var(--space-1);cursor:pointer;padding:var(--space-1) var(--space-2);border-radius:var(--radius-medium);border:1px solid var(--border);background:transparent;color:var(--foreground);transition:background .15s,border-color .15s;line-height:1}.oat-ts-trigger:hover{background:var(--accent)}.oat-ts-trigger:focus-visible{outline:2px solid var(--ring);outline-offset:2px}.oat-ts-chevron{width:.75rem;height:.75rem;opacity:.5}.oat-ts-menu{position:absolute;top:calc(100% + var(--space-1));right:0;z-index:50;min-width:8rem;border:1px solid var(--border);border-radius:var(--radius-medium);background:var(--background);box-shadow:var(--shadow-small);padding:var(--space-1);display:flex;flex-direction:column;animation:oat-ts-fade-in .12s ease-out}@keyframes oat-ts-fade-in{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.oat-ts-menu button{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-2);border-radius:var(--radius-small);cursor:pointer;font-size:var(--text-6);line-height:1.25;color:var(--foreground);transition:background .1s}.oat-ts-menu button:hover,.oat-ts-menu button:focus-visible{background:var(--accent)}.oat-ts-menu button:focus-visible{outline:2px solid var(--ring);outline-offset:-2px}.oat-ts-menu button.active{font-weight:500}.oat-ts-check{width:.875rem;height:.875rem;margin-inline-start:auto;opacity:.7}.oat-ts-toggle{display:inline-flex;border:1px solid var(--border);border-radius:var(--radius-medium);overflow:hidden}.oat-ts-toggle button{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;padding:var(--space-1) var(--space-2);cursor:pointer;transition:background .15s;color:var(--muted-foreground, var(--foreground))}.oat-ts-toggle button:hover{background:var(--accent)}.oat-ts-toggle button:focus-visible{outline:2px solid var(--ring);outline-offset:-2px}.oat-ts-toggle button.active{background:var(--accent);color:var(--foreground)}.oat-ts-toggle button+button{border-left:1px solid var(--border)}\n"] }]
4967
+ }], propDecorators: { mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], initialTheme: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialTheme", required: false }] }], themes: [{ type: i0.Input, args: [{ isSignal: true, alias: "themes", required: false }] }], themeChange: [{ type: i0.Output, args: ["themeChange"] }], iconTpl: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgOatThemeSelectorIcon), { isSignal: true }] }] } });
4968
+
4327
4969
  /**
4328
4970
  * Oat-styled text input implementing `FormValueControl<string>`.
4329
4971
  * Works seamlessly with Signal Forms `[formField]` — no CVA needed.
@@ -4868,5 +5510,5 @@ const OAT_VERSION_TOKEN = new InjectionToken('OAT_VERSION', {
4868
5510
  * Generated bundle index. Do not edit.
4869
5511
  */
4870
5512
 
4871
- export { NG_OAT_CHIP_GROUP, NG_OAT_TOGGLE_GROUP, NgOatAccordion, NgOatAlert, NgOatAvatar, NgOatBadge, NgOatBreadcrumb, NgOatButton, NgOatCard, NgOatCardCarousel, NgOatCardFooter, NgOatCardHeader, NgOatCarousel, NgOatCheckbox, NgOatChip, NgOatChipGroup, NgOatChipInput, NgOatDialog, NgOatDialogComponent, NgOatDropdown, NgOatDropdownComponent, NgOatFileUpload, NgOatFormError, NgOatInput, NgOatInputOtp, NgOatMeter, NgOatPagination, NgOatProgress, NgOatRadioGroup, NgOatSearchInput, NgOatSelect, NgOatSeparator, NgOatSidebar, NgOatSidebarComponent, NgOatSkeleton, NgOatSpinner, NgOatSplitButton, NgOatSwitch, NgOatTable, NgOatTabs, NgOatTabsComponent, NgOatTextarea, NgOatThemeRef, NgOatToast, NgOatToggle, NgOatToggleGroup, NgOatTooltip, NgOatTooltipComponent, OAT_TOKEN_MAP, OAT_VERSION, OAT_VERSION_TOKEN, TOOLTIP_POSITIONER, provideNgOat, provideNgOatTheme };
5513
+ export { NG_OAT_CHIP_GROUP, NG_OAT_TOGGLE_GROUP, NgOatAccordion, NgOatAlert, NgOatAvatar, NgOatBadge, NgOatBreadcrumb, NgOatButton, NgOatCard, NgOatCardCarousel, NgOatCardFooter, NgOatCardHeader, NgOatCarousel, NgOatCheckbox, NgOatChip, NgOatChipGroup, NgOatChipInput, NgOatDialog, NgOatDialogComponent, NgOatDropdown, NgOatDropdownComponent, NgOatFileUpload, NgOatFormError, NgOatInput, NgOatInputOtp, NgOatMeter, NgOatPagination, NgOatProgress, NgOatRadioGroup, NgOatSearchInput, NgOatSelect, NgOatSeparator, NgOatSidebar, NgOatSidebarComponent, NgOatSkeleton, NgOatSpinner, NgOatSplitButton, NgOatSwitch, NgOatTable, NgOatTabs, NgOatTabsComponent, NgOatTextarea, NgOatThemeRef, NgOatThemeSelector, NgOatThemeSelectorIcon, NgOatToast, NgOatToggle, NgOatToggleGroup, NgOatToolbar, NgOatToolbarRow, NgOatTooltip, NgOatTooltipComponent, OAT_TOKEN_MAP, OAT_VERSION, OAT_VERSION_TOKEN, TOOLTIP_POSITIONER, provideNgOat, provideNgOatTheme };
4872
5514
  //# sourceMappingURL=letsprogram-ng-oat.mjs.map