@yuuvis/material 2.17.0 → 2.19.0

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,9 +1,9 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Inject, Injectable, inject, signal, makeEnvironmentProviders, input, booleanAttribute, ElementRef, EnvironmentInjector, ApplicationRef, effect, createComponent, Directive, DestroyRef, viewChild, untracked, Input, HostBinding, HostListener, ChangeDetectionStrategy, Component } from '@angular/core';
2
+ import { Inject, Injectable, inject, signal, makeEnvironmentProviders, provideEnvironmentInitializer, input, booleanAttribute, ElementRef, EnvironmentInjector, ApplicationRef, effect, createComponent, Directive, DestroyRef, viewChild, untracked, Input, HostBinding, HostListener, ChangeDetectionStrategy, Component } from '@angular/core';
3
3
  import { MAT_NATIVE_DATE_FORMATS, DateAdapter, MAT_DATE_FORMATS, NativeDateAdapter, MAT_DATE_LOCALE, MAT_RIPPLE_GLOBAL_OPTIONS, NativeDateModule } from '@angular/material/core';
4
+ import { MatDialogConfig, MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog';
4
5
  import { MAT_FORM_FIELD_DEFAULT_OPTIONS, MatFormField, MatFormFieldControl } from '@angular/material/form-field';
5
6
  import { MatIconRegistry } from '@angular/material/icon';
6
- import { MatDialogConfig, MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog';
7
7
  import { MatPaginatorIntl } from '@angular/material/paginator';
8
8
  import { MAT_TABS_CONFIG } from '@angular/material/tabs';
9
9
  import { MAT_TOOLTIP_DEFAULT_OPTIONS } from '@angular/material/tooltip';
@@ -15,7 +15,7 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion';
15
15
  import { MatDatepickerIntl, MatDatepicker, MatDatepickerInput, MatDateRangeInput, MatDateRangePicker, MatEndDate, MatStartDate } from '@angular/material/datepicker';
16
16
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
17
17
  import { DeviceDetectorService } from 'ngx-device-detector';
18
- import { fromEvent, debounceTime, ReplaySubject, Subject } from 'rxjs';
18
+ import { fromEvent, debounceTime, ReplaySubject, BehaviorSubject, Subject } from 'rxjs';
19
19
  import { MatIconButton, MatButton } from '@angular/material/button';
20
20
  import { MatInput } from '@angular/material/input';
21
21
  import * as i1$1 from '@angular/forms';
@@ -174,35 +174,54 @@ var DeviceScreenOrientation;
174
174
  })(DeviceScreenOrientation || (DeviceScreenOrientation = {}));
175
175
 
176
176
  /**
177
- * This service is used to adapt styles and designs of the client to
178
- * different devices and screen sizes.
179
- *
180
- * Using `screenChange$` observable you are able to monitor changes to
181
- * the screen size and act upon it.
182
- *
183
- * This service will also adds attributes to the body tag that reflect the
184
- * current screen/device state. This way you can apply secific styles in your
185
- * css files for different screen resolutions and orientations.
177
+ * Service that adapts the application layout and behavior to different devices,
178
+ * screen sizes, orientations, and browser zoom levels.
186
179
  *
187
- * Attributes applied to the body tag are:
180
+ * This service operates globally as a singleton, listening to window resize events
181
+ * and maintaining reactive state about the current screen and zoom conditions.
182
+ * It also writes attributes to the `<body>` element so that CSS rules can respond
183
+ * to screen state without requiring Angular bindings.
188
184
  *
189
- * - `data-screen` - [s, m, l, xl] - for different screen sizes
190
- * (s: for mobile phone like screen sizes, m: for tablet like screen
191
- * sizes, 'l': for desktop like screen sizes, 'xl': for screen sizes exceeding
192
- * the desktop screen size).
185
+ * **Key Features:**
186
+ * - Screen size classification (s / m / l / xl) based on viewport dimensions
187
+ * - Orientation detection (portrait / landscape), with native API fallback on mobile
188
+ * - Small-screen layout signal for toggling compact UI modes
189
+ * - Browser zoom level tracking via `pageZoomPercentage$`
190
+ * - Automatic `<body>` attribute management for CSS-driven responsive styles
193
191
  *
194
- * - `data-orientation` - [portrait, landscape] - for the current screen orientation
192
+ * **Usage:**
193
+ * 1. `DeviceService` is initialized automatically by `provideYmtMaterial()` in `app.config.ts`
194
+ * 2. Pass `{ supportsSmallScreens: true }` to `provideYmtMaterial()` if the app has a dedicated compact layout
195
+ * 3. Inject `DeviceService` wherever needed — it is already initialized
196
+ * 4. Subscribe to `screenChange$` or `pageZoomPercentage$` to react to changes
195
197
  *
196
- * - `data-touch-enabled` - [true] - if the device has touch capabilities (won't be added if the device doesn't have touch capabilities)
198
+ * **Body attributes written by this service:**
199
+ * - `data-screen-size` — current size bucket: `s` | `m` | `l` | `xl`
200
+ * - `data-screen-orientation` — `portrait` | `landscape`
201
+ * - `data-touch-enabled` — present only when the device supports touch input
197
202
  *
198
203
  * ```html
199
- * <body data-screen-size="s" data-screen-orientation="portrait" data-touch-enabled="true">
200
- * ...
204
+ * <body data-screen-size="l" data-screen-orientation="landscape">
205
+ * ...
201
206
  * </body>
202
207
  * ```
208
+ *
209
+ * **Screen size boundaries:**
210
+ *
211
+ * | Size | Range | Device |
212
+ * |------|----------------------------|-----------------------------------|
213
+ * | `s` | < 600px | mobile phone |
214
+ * | `m` | 600px – 900/1200px | tablet (upper bound by orientation) |
215
+ * | `l` | 900/1200px – 1800px | desktop |
216
+ * | `xl` | ≥ 1800px | wide / large desktop |
217
+ *
218
+ * @see {@link DeviceScreen} for the screen state shape emitted by `screenChange$`
203
219
  */
204
220
  class DeviceService {
221
+ //#region Dependencies
205
222
  #deviceDetectorService = inject(DeviceDetectorService);
223
+ //#endregion
224
+ //#region Properties
206
225
  #upperScreenBoundary = {
207
226
  small: 600,
208
227
  mediumPortrait: 900,
@@ -212,36 +231,126 @@ class DeviceService {
212
231
  #resize$ = fromEvent(window, 'resize').pipe(debounceTime(this.#getDebounceTime()));
213
232
  #screen;
214
233
  #screenSource = new ReplaySubject(1);
234
+ /**
235
+ * Emits the current {@link DeviceScreen} state whenever the window is resized.
236
+ *
237
+ * The emitted value includes the size bucket (`s` | `m` | `l` | `xl`), orientation,
238
+ * and the raw viewport dimensions in pixels. Use this stream to reactively adapt
239
+ * layout or behavior whenever the screen state changes.
240
+ *
241
+ * Replay buffer of 1 — late subscribers always receive the most recent value
242
+ * without waiting for the next resize event.
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * deviceService.screenChange$.subscribe(screen => {
247
+ * console.log(screen.size); // 'l'
248
+ * console.log(screen.orientation); // 'landscape'
249
+ * console.log(screen.width); // 1440
250
+ * });
251
+ * ```
252
+ */
215
253
  screenChange$ = this.#screenSource.asObservable();
216
254
  /**
217
- * Signal to indicate if the screen size is small (e.g. mobile phone).
218
- * This will only be triggered if `supportsSmallScreens` is set to true.
219
- * Major components will use this metric to adapt to 'small screen behavior' and so can you
255
+ * Signal that indicates whether the app should render in a compact small-screen layout.
256
+ *
257
+ * This is `true` only when all three conditions are met:
258
+ * 1. `supportsSmallScreens` was passed as `true` to {@link init}
259
+ * 2. The current screen size bucket is `s` (< 600px viewport width)
260
+ * 3. The current orientation is `portrait`
261
+ *
262
+ * Components that have a dedicated compact mode should read this signal
263
+ * and switch their layout accordingly.
264
+ *
265
+ * @default false
220
266
  */
221
267
  smallScreenLayout = signal(false);
222
268
  #supportsSmallScreens = signal(false);
223
269
  /**
224
- * if the device is a mobile device (android / iPhone / windows-phone etc)
270
+ * `true` if the current device is a mobile phone (Android, iPhone, Windows Phone, etc.).
271
+ * Populated once at service creation from `ngx-device-detector`.
225
272
  */
226
273
  isMobile = this.#deviceDetectorService.isMobile();
227
274
  /**
228
- * if the device us a tablet (iPad etc)
275
+ * `true` if the current device is a tablet (e.g. iPad).
276
+ * Populated once at service creation from `ngx-device-detector`.
229
277
  */
230
278
  isTablet = this.#deviceDetectorService.isTablet();
231
279
  /**
232
- * if the app is running on a Desktop browser
280
+ * `true` if the app is running in a desktop browser.
281
+ * Populated once at service creation from `ngx-device-detector`.
233
282
  */
234
283
  isDesktop = this.#deviceDetectorService.isDesktop();
284
+ /**
285
+ * Detailed device and browser information provided by `ngx-device-detector`.
286
+ * Includes OS, browser name and version, device type, and user agent.
287
+ */
235
288
  info = this.#deviceDetectorService.getDeviceInfo();
289
+ /**
290
+ * `true` if the device supports touch input.
291
+ * Detected via `ontouchstart` presence or `navigator.maxTouchPoints > 0`.
292
+ */
236
293
  isTouchEnabled = this.#isTouchEnabled();
237
- constructor() {
238
- this.#resize$.subscribe((e) => {
239
- this.#setScreen();
240
- });
241
- }
294
+ #pageZoom$ = new BehaviorSubject(0);
295
+ /**
296
+ * Emits the current browser zoom level as a percentage whenever it changes.
297
+ *
298
+ * The value is derived from `window.outerWidth / window.innerWidth * 100`.
299
+ * At 100% zoom the value is `100`, at 150% it is `150`, and so on.
300
+ *
301
+ * Emits only when the value actually changes (no duplicate emissions).
302
+ * Updates are driven by the window `resize` event, which all major browsers
303
+ * fire when the user changes the zoom level via Ctrl+/- or Ctrl+scroll.
304
+ * On desktop there is a 500ms debounce; on mobile, no debounce is applied.
305
+ *
306
+ * @example
307
+ * ```ts
308
+ * deviceService.pageZoomPercentage$.subscribe(zoom => {
309
+ * console.log(`Current zoom: ${zoom}%`); // e.g. "Current zoom: 150%"
310
+ * });
311
+ * ```
312
+ */
313
+ pageZoomPercentage$ = this.#pageZoom$.asObservable();
314
+ //#endregion
315
+ //#region Public APIs
316
+ /**
317
+ * Initializes the service and performs the first screen state evaluation.
318
+ *
319
+ * Called automatically by `provideYmtMaterial()` during application startup.
320
+ *
321
+ * Immediately evaluates the current viewport dimensions, classifies the screen size
322
+ * and orientation, writes the result to the `<body>` attributes, and emits the initial
323
+ * value on `screenChange$`. Subsequent updates are handled automatically via the
324
+ * internal resize listener.
325
+ *
326
+ * @param supportsSmallScreens - Set to `true` if the application has a dedicated compact
327
+ * layout for small portrait screens. When `true`, the {@link smallScreenLayout} signal
328
+ * will be set to `true` whenever the screen bucket is `s` and orientation is `portrait`.
329
+ * Defaults to `false`.
330
+ */
242
331
  init(supportsSmallScreens = false) {
243
332
  this.#supportsSmallScreens.set(supportsSmallScreens);
244
333
  this.#setScreen();
334
+ this.#setPageZoom();
335
+ this.#handleScreenResize();
336
+ }
337
+ //#endregion
338
+ //#region Utilities
339
+ #getPageZoom() {
340
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
341
+ return (window.outerWidth / window.innerWidth) * 100;
342
+ }
343
+ #setPageZoom() {
344
+ const zoom = this.#getPageZoom();
345
+ if (zoom !== this.#pageZoom$.value) {
346
+ this.#pageZoom$.next(zoom);
347
+ }
348
+ }
349
+ #handleScreenResize() {
350
+ this.#resize$.subscribe(() => {
351
+ this.#setScreen();
352
+ this.#setPageZoom();
353
+ });
245
354
  }
246
355
  #isTouchEnabled() {
247
356
  return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
@@ -252,10 +361,12 @@ class DeviceService {
252
361
  height: window.innerHeight
253
362
  };
254
363
  let orientation = bounds.width >= bounds.height ? DeviceScreenOrientation.LANDSCAPE : DeviceScreenOrientation.PORTRAIT;
364
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
255
365
  if (this.isMobile && window.screen['orientation']) {
256
366
  const screenOrientation = window.screen['orientation'].type;
257
367
  if (screenOrientation === 'landscape-primary' || screenOrientation === 'landscape-secondary') {
258
368
  orientation = DeviceScreenOrientation.LANDSCAPE;
369
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
259
370
  }
260
371
  else if (screenOrientation === 'portrait-primary' || screenOrientation === 'portrait-secondary') {
261
372
  orientation = DeviceScreenOrientation.PORTRAIT;
@@ -288,7 +399,9 @@ class DeviceService {
288
399
  if (this.#isBelow(this.#upperScreenBoundary.small, bounds)) {
289
400
  return 's';
290
401
  }
291
- else if (this.#isBelow(orientation === 'landscape' ? this.#upperScreenBoundary.mediumLandscape : this.#upperScreenBoundary.mediumPortrait, bounds)) {
402
+ else if (this.#isBelow(orientation === 'landscape'
403
+ ? this.#upperScreenBoundary.mediumLandscape
404
+ : this.#upperScreenBoundary.mediumPortrait, bounds)) {
292
405
  return 'm';
293
406
  }
294
407
  else if (this.#isBelow(this.#upperScreenBoundary.large, bounds)) {
@@ -304,7 +417,7 @@ class DeviceService {
304
417
  }
305
418
  #getDebounceTime() {
306
419
  // on mobile devices resize only happens when rotating the device or when
307
- // keyboard appears, so we dont't need to debounce
420
+ // keyboard appears, so we don't need to debounce
308
421
  return this.isMobile ? 0 : 500;
309
422
  }
310
423
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: DeviceService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -315,7 +428,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImpo
315
428
  args: [{
316
429
  providedIn: 'root'
317
430
  }]
318
- }], ctorParameters: () => [] });
431
+ }] });
319
432
 
320
433
  /* Draft */
321
434
  class YmtMatPaginatorIntlService extends MatPaginatorIntl {
@@ -357,12 +470,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.20", ngImpo
357
470
  type: Injectable
358
471
  }] });
359
472
 
360
- const provideYmtMaterial = (customTheme) => {
473
+ const provideYmtMaterial = (customTheme, options) => {
361
474
  const providers = [
362
475
  /**
363
476
  * Material Date Locale Default
364
477
  */
365
- { provide: MAT_DATE_LOCALE, useFactory: (translate) => translate.defaultLang, deps: [TranslateService] },
478
+ {
479
+ provide: MAT_DATE_LOCALE,
480
+ useFactory: (translate) => translate.defaultLang,
481
+ deps: [TranslateService]
482
+ },
366
483
  { provide: MAT_DATE_FORMATS, useValue: YMT_DATE_FORMATS },
367
484
  /**
368
485
  * Material Datepicker Internationalization Service Override
@@ -444,7 +561,12 @@ const provideYmtMaterial = (customTheme) => {
444
561
  }
445
562
  }
446
563
  ];
447
- return makeEnvironmentProviders(providers);
564
+ return makeEnvironmentProviders([
565
+ ...providers,
566
+ provideEnvironmentInitializer(() => {
567
+ inject(DeviceService).init(options?.supportsSmallScreens);
568
+ })
569
+ ]);
448
570
  };
449
571
 
450
572
  var YMT_ICON_BUTTON_SIZE;
@@ -521,12 +643,12 @@ var YMT_BUTTON_SIZE;
521
643
  YMT_BUTTON_SIZE["medium"] = "medium";
522
644
  })(YMT_BUTTON_SIZE || (YMT_BUTTON_SIZE = {}));
523
645
 
524
- var MatButtonAttributeEnum;
525
- (function (MatButtonAttributeEnum) {
526
- MatButtonAttributeEnum["primary"] = "mat-flat-button";
527
- MatButtonAttributeEnum["secondary"] = "mat-stroked-button";
528
- MatButtonAttributeEnum["tertiary"] = "mat-button";
529
- })(MatButtonAttributeEnum || (MatButtonAttributeEnum = {}));
646
+ const MatButtonAttributeMap = {
647
+ primary: 'mat-flat-button',
648
+ secondary: 'mat-stroked-button',
649
+ tertiary: 'mat-button',
650
+ danger: 'mat-flat-button'
651
+ };
530
652
  class YmtButtonDirective {
531
653
  ymtButton = input('primary');
532
654
  disabled = input(false, { transform: booleanAttribute });
@@ -542,7 +664,7 @@ class YmtButtonDirective {
542
664
  #iconPrefix = [];
543
665
  #noneIconNodes = [];
544
666
  #iconSuffix = [];
545
- #matButtonAttribute = MatButtonAttributeEnum;
667
+ #matButtonAttribute = MatButtonAttributeMap;
546
668
  constructor() {
547
669
  effect(() => {
548
670
  const disabled = this.disabled();
@@ -583,6 +705,9 @@ class YmtButtonDirective {
583
705
  }
584
706
  #setInitialAttributesToHost() {
585
707
  this.#hostElement.setAttribute(this.#matButtonAttribute[this.ymtButton()], '');
708
+ if (this.ymtButton() === 'danger') {
709
+ this.#hostElement.classList.add('ymt-button--danger');
710
+ }
586
711
  }
587
712
  #getIconPrefixElements() {
588
713
  return Array.from(this.#hostElement.querySelectorAll('mat-icon:not([iconPositionEnd])'));
@@ -592,7 +717,9 @@ class YmtButtonDirective {
592
717
  }
593
718
  #getNoneIconNodes() {
594
719
  const iter = document.createNodeIterator(this.#hostElement, NodeFilter.SHOW_ALL, (node) => {
595
- return node.nodeName.toLowerCase() !== 'mat-icon' && node.parentElement === this.#hostElement ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
720
+ return node.nodeName.toLowerCase() !== 'mat-icon' && node.parentElement === this.#hostElement
721
+ ? NodeFilter.FILTER_ACCEPT
722
+ : NodeFilter.FILTER_REJECT;
596
723
  });
597
724
  let currentNode;
598
725
  const filteredNodes = [];
@@ -602,8 +729,12 @@ class YmtButtonDirective {
602
729
  return filteredNodes;
603
730
  }
604
731
  #applySizeClass() {
605
- this.size() === YMT_BUTTON_SIZE.small ? this.#hostElement.classList.add(`ymt-button--size-s`) : this.#hostElement.classList.remove(`ymt-button--size-s`);
606
- this.size() === YMT_BUTTON_SIZE.medium ? this.#hostElement.classList.add(`ymt-button--size-m`) : this.#hostElement.classList.remove(`ymt-button--size-m`);
732
+ this.size() === YMT_BUTTON_SIZE.small
733
+ ? this.#hostElement.classList.add(`ymt-button--size-s`)
734
+ : this.#hostElement.classList.remove(`ymt-button--size-s`);
735
+ this.size() === YMT_BUTTON_SIZE.medium
736
+ ? this.#hostElement.classList.add(`ymt-button--size-m`)
737
+ : this.#hostElement.classList.remove(`ymt-button--size-m`);
607
738
  }
608
739
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.20", ngImport: i0, type: YmtButtonDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
609
740
  static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.20", type: YmtButtonDirective, isStandalone: true, selector: "button[ymtButton], a[ymtButton]", inputs: { ymtButton: { classPropertyName: "ymtButton", publicName: "ymtButton", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, ariaDisabled: { classPropertyName: "ariaDisabled", publicName: "aria-disabled", isSignal: true, isRequired: false, transformFunction: null }, disableRipple: { classPropertyName: "disableRipple", publicName: "disableRipple", isSignal: true, isRequired: false, transformFunction: null }, disabledInteractive: { classPropertyName: "disabledInteractive", publicName: "disabledInteractive", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "button-size", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });