ngx-dynamic-entity 2.1.1 → 2.2.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.
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, ElementRef, computed, afterNextRender, HostListener, ViewChildren, Output } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
4
  import { ReactiveFormsModule, Validators, FormBuilder, FormGroup, FormArray, FormsModule } from '@angular/forms';
5
- import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, getTabPath, fieldRefFor, toRefToken, refOf, parseFieldRef, setTabData, setValueByPath, normalizeArrayStructures, getTabData, getValueByPath, normalizeConfigOptions, migrateRecord, ambiguousFieldIds, collectFieldScopes, applyAutoPatch, isUnsafePath, fieldsUnderTab, valuesEqual, parseCsv, deriveImportColumns, suggestMapping, applyMapping, toCsv, stripIndices, CORE_VERSION, coerceCell, buildTemplateSpec, collectLeafTargets, formatConfigProblems } from '@dynamic-entity/core';
5
+ import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, isValidUrl, isValidPhone, fileRefName, sliderBounds, ratingScale, normalizeHexColor, normalizeTags, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, getTabPath, fieldRefFor, toRefToken, refOf, parseFieldRef, setTabData, setValueByPath, normalizeArrayStructures, getTabData, getValueByPath, normalizeConfigOptions, migrateRecord, ambiguousFieldIds, collectFieldScopes, applyAutoPatch, isUnsafePath, fieldsUnderTab, valuesEqual, parseCsv, deriveImportColumns, suggestMapping, applyMapping, toCsv, stripIndices, CORE_VERSION, coerceCell, buildTemplateSpec, collectLeafTargets, formatConfigProblems } from '@dynamic-entity/core';
6
6
  export { formatDisplayValue, normalizeConfig, normalizeOption, resolveLabel, resolveOptionLabel, resolveOptionValue, valuesMatch } from '@dynamic-entity/core';
7
7
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
8
8
  import { Subject, isObservable, firstValueFrom } from 'rxjs';
@@ -302,6 +302,10 @@ const DEFAULT_VALIDATION_MESSAGES = {
302
302
  required: 'This field is required.',
303
303
  requiredSelection: 'Please select an option.',
304
304
  email: 'Please enter a valid email address.',
305
+ url: 'Please enter a full web address, starting with https://.',
306
+ phone: 'Please enter a valid phone number.',
307
+ minItems: (_lang, err) => `At least ${err?.requiredLength} required.`,
308
+ maxItems: (_lang, err) => `No more than ${err?.requiredLength} allowed.`,
305
309
  pattern: 'Invalid format.',
306
310
  minlength: (_lang, err) => `Minimum ${err?.requiredLength} characters required.`,
307
311
  maxlength: (_lang, err) => `Maximum ${err?.requiredLength} characters allowed.`,
@@ -318,18 +322,35 @@ const DEFAULT_VALIDATION_MESSAGES = {
318
322
  * first error actually on the control — so the shared list costs nothing and spares every
319
323
  * field component a near-identical copy of it.
320
324
  */
321
- const COMMON_ORDER = ['required', 'email', 'min', 'max', 'minlength', 'maxlength', 'pattern'];
325
+ const COMMON_ORDER = [
326
+ 'required',
327
+ 'email',
328
+ 'url',
329
+ 'phone',
330
+ 'min',
331
+ 'max',
332
+ 'minlength',
333
+ 'maxlength',
334
+ 'pattern',
335
+ ];
322
336
  /**
323
337
  * Where a field type disagrees with the common list.
324
338
  *
325
- * Only three do, and each for a reason about how the control reads rather than what it can
326
- * raise: a select says "Please select an option" where an input says "This field is
327
- * required"; a number that is neither required nor out of range is not "invalid", it is not a
328
- * number; and a control with no text in it cannot fail a length or format rule.
339
+ * Each for a reason about how the control reads rather than what it can raise: a select says
340
+ * "Please select an option" where an input says "This field is required"; a number that is
341
+ * neither required nor out of range is not "invalid", it is not a number; a control with no
342
+ * text in it cannot fail a length or format rule; and a `tags` list is counted in items, not
343
+ * characters, although Angular reports both as `minlength`.
329
344
  */
330
345
  const ORDER_BY_FIELD_TYPE = {
331
346
  dropdown: { order: [['required', 'requiredSelection']], fallback: 'invalidSelection' },
332
347
  number: { order: ['required', 'min', 'max'], fallback: 'invalidNumber' },
348
+ slider: { order: ['required', 'min', 'max'], fallback: 'invalid' },
349
+ rating: { order: ['required', 'min', 'max'], fallback: 'invalid' },
350
+ tags: {
351
+ order: ['required', ['minlength', 'minItems'], ['maxlength', 'maxItems'], 'pattern'],
352
+ fallback: 'invalid',
353
+ },
333
354
  boolean: { order: ['required', 'pattern'], fallback: 'invalid' },
334
355
  checkbox: { order: ['required', 'pattern'], fallback: 'invalid' },
335
356
  file: { order: ['required', 'pattern'], fallback: 'invalid' },
@@ -854,6 +875,11 @@ const DEFAULT_UI_TEXT = {
854
875
  noImage: 'No image',
855
876
  uploadImage: 'Upload',
856
877
  changeImage: 'Change',
878
+ // Rating, colour and tags
879
+ ratingValue: '{value} of {max}',
880
+ clearValue: 'Clear {field}',
881
+ addTagPlaceholder: 'Type and press Enter',
882
+ removeTag: 'Remove {tag}',
857
883
  // Month / year pickers
858
884
  month: 'Month',
859
885
  year: 'Year',
@@ -5195,6 +5221,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
5195
5221
  type: Input
5196
5222
  }] } });
5197
5223
 
5224
+ /**
5225
+ * A format check that, like `Validators.email`, passes an empty value.
5226
+ *
5227
+ * Emptiness is `required`'s question. Answering it here too would give an optional field an
5228
+ * error the moment it was cleared.
5229
+ */
5230
+ function formatValidator(key, test) {
5231
+ return (control) => {
5232
+ const value = control.value;
5233
+ if (value === null || value === undefined || value === '')
5234
+ return null;
5235
+ return test(String(value)) ? null : { [key]: true };
5236
+ };
5237
+ }
5238
+ /** The built-in web address check. The rule itself is in core, so an import applies the same one. */
5239
+ const urlValidator = formatValidator('url', isValidUrl);
5240
+ /** The built-in phone number check. The rule itself is in core, so an import applies the same one. */
5241
+ const phoneValidator = formatValidator('phone', isValidPhone);
5198
5242
  /**
5199
5243
  * ValidatorRegistryService — resolves validator configs / key strings to Angular ValidatorFns.
5200
5244
  */
@@ -5213,6 +5257,10 @@ class ValidatorRegistryService {
5213
5257
  return Validators.required;
5214
5258
  if (validatorKey === 'email')
5215
5259
  return Validators.email;
5260
+ if (validatorKey === 'url')
5261
+ return urlValidator;
5262
+ if (validatorKey === 'phone')
5263
+ return phoneValidator;
5216
5264
  const [name, param] = validatorKey.split(':');
5217
5265
  const value = parseFloat(param);
5218
5266
  if (name === 'min' && !isNaN(value))
@@ -5262,6 +5310,10 @@ class ValidatorRegistryService {
5262
5310
  }
5263
5311
  if (config.email)
5264
5312
  fnList.push(Validators.email);
5313
+ if (config.url)
5314
+ fnList.push(urlValidator);
5315
+ if (config.phone)
5316
+ fnList.push(phoneValidator);
5265
5317
  // Named validators from the consumer registry, so a registered validator can be named
5266
5318
  // from a typed schema.
5267
5319
  for (const key of config.custom ?? []) {
@@ -6151,12 +6203,1492 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImpo
6151
6203
  type: Input
6152
6204
  }] } });
6153
6205
 
6206
+ /**
6207
+ * URL field: a `type="url"` input, and a link when read-only.
6208
+ *
6209
+ * The read-only link is rendered only for a value that passes `isValidUrl`, meaning an http or
6210
+ * https address. A record can hold anything, including `javascript:`, and a field whose job is
6211
+ * to show an address must not turn a stored script into something to click. Anything else is
6212
+ * shown as plain text.
6213
+ */
6214
+ class UrlFieldComponent {
6215
+ /**
6216
+ * Unique to this component instance: an `array` renders the same field once per row, and a
6217
+ * DOM id may not repeat. See `field-dom-id.ts`.
6218
+ */
6219
+ instanceId = nextFieldInstanceId();
6220
+ domId(suffix = '') {
6221
+ return fieldDomId(this.field, this.instanceId, suffix);
6222
+ }
6223
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
6224
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
6225
+ messages = inject(ValidationMessagesService);
6226
+ field;
6227
+ control;
6228
+ language = 'en';
6229
+ readonly = false;
6230
+ masked = false;
6231
+ get label() {
6232
+ return resolveLabel(this.field?.label, this.language);
6233
+ }
6234
+ get placeholder() {
6235
+ return resolveLabel(this.field?.placeholder, this.language);
6236
+ }
6237
+ /** Author help text, shown under the control and named by `aria-describedby`. */
6238
+ get hint() {
6239
+ return resolveLabel(this.field?.hint, this.language);
6240
+ }
6241
+ /** Whether the stored value is an http(s) address, and so safe to render as a link. */
6242
+ get linkable() {
6243
+ const value = this.control?.value;
6244
+ return typeof value === 'string' && isValidUrl(value);
6245
+ }
6246
+ /** The ids of whatever is describing this control right now. */
6247
+ describedBy() {
6248
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
6249
+ }
6250
+ get errorMessage() {
6251
+ if (!this.control?.errors || !this.control.touched)
6252
+ return '';
6253
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
6254
+ }
6255
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: UrlFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6256
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: UrlFieldComponent, isStandalone: true, selector: "ngx-url-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
6257
+ <div
6258
+ class="ngx-field ngx-field--url"
6259
+ [attr.data-testid]="'field-' + field.id"
6260
+ [attr.data-field-type]="field.type"
6261
+ [class.ngx-field--readonly]="readonly"
6262
+ [class.ngx-field--masked]="masked"
6263
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6264
+ >
6265
+ <div class="ngx-field__label-row">
6266
+ <label class="ngx-field__label" [attr.for]="domId()">
6267
+ {{ label }}
6268
+ @if (field.validators?.required) {
6269
+ <span class="ngx-field__req">*</span>
6270
+ }
6271
+ </label>
6272
+ @if (hint) {
6273
+ <span class="ngx-field__hint-wrap">
6274
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6275
+ <span
6276
+ class="ngx-field__hint"
6277
+ role="tooltip"
6278
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6279
+ [id]="domId('-hint')"
6280
+ >{{ hint }}</span
6281
+ >
6282
+ </span>
6283
+ }
6284
+ </div>
6285
+ @if (masked) {
6286
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6287
+ maskedText
6288
+ }}</span>
6289
+ } @else if (readonly) {
6290
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6291
+ @if (linkable) {
6292
+ <a [href]="control.value" class="ngx-field__link" target="_blank" rel="noopener noreferrer">{{
6293
+ control.value
6294
+ }}</a>
6295
+ } @else {
6296
+ {{ control.value || '—' }}
6297
+ }
6298
+ </span>
6299
+ } @else {
6300
+ <input
6301
+ [id]="domId()"
6302
+ class="ngx-field__input"
6303
+ [attr.data-testid]="'field-' + field.id + '-input'"
6304
+ type="url"
6305
+ inputmode="url"
6306
+ autocomplete="url"
6307
+ [formControl]="$any(control)"
6308
+ [attr.aria-invalid]="control.invalid && control.touched"
6309
+ [attr.aria-describedby]="describedBy()"
6310
+ [placeholder]="placeholder || 'https://example.com'"
6311
+ [attr.disabled]="field.disabled ? true : null"
6312
+ />
6313
+ @if (errorMessage) {
6314
+ <span
6315
+ class="ngx-field__error"
6316
+ [attr.data-testid]="'field-' + field.id + '-error'"
6317
+ [id]="domId('-error')"
6318
+ role="alert"
6319
+ >{{ errorMessage }}</span
6320
+ >
6321
+ }
6322
+ }
6323
+ </div>
6324
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6325
+ }
6326
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: UrlFieldComponent, decorators: [{
6327
+ type: Component,
6328
+ args: [{
6329
+ changeDetection: ChangeDetectionStrategy.OnPush,
6330
+ selector: 'ngx-url-field',
6331
+ standalone: true,
6332
+ imports: [ReactiveFormsModule],
6333
+ template: `
6334
+ <div
6335
+ class="ngx-field ngx-field--url"
6336
+ [attr.data-testid]="'field-' + field.id"
6337
+ [attr.data-field-type]="field.type"
6338
+ [class.ngx-field--readonly]="readonly"
6339
+ [class.ngx-field--masked]="masked"
6340
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6341
+ >
6342
+ <div class="ngx-field__label-row">
6343
+ <label class="ngx-field__label" [attr.for]="domId()">
6344
+ {{ label }}
6345
+ @if (field.validators?.required) {
6346
+ <span class="ngx-field__req">*</span>
6347
+ }
6348
+ </label>
6349
+ @if (hint) {
6350
+ <span class="ngx-field__hint-wrap">
6351
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6352
+ <span
6353
+ class="ngx-field__hint"
6354
+ role="tooltip"
6355
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6356
+ [id]="domId('-hint')"
6357
+ >{{ hint }}</span
6358
+ >
6359
+ </span>
6360
+ }
6361
+ </div>
6362
+ @if (masked) {
6363
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6364
+ maskedText
6365
+ }}</span>
6366
+ } @else if (readonly) {
6367
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6368
+ @if (linkable) {
6369
+ <a [href]="control.value" class="ngx-field__link" target="_blank" rel="noopener noreferrer">{{
6370
+ control.value
6371
+ }}</a>
6372
+ } @else {
6373
+ {{ control.value || '—' }}
6374
+ }
6375
+ </span>
6376
+ } @else {
6377
+ <input
6378
+ [id]="domId()"
6379
+ class="ngx-field__input"
6380
+ [attr.data-testid]="'field-' + field.id + '-input'"
6381
+ type="url"
6382
+ inputmode="url"
6383
+ autocomplete="url"
6384
+ [formControl]="$any(control)"
6385
+ [attr.aria-invalid]="control.invalid && control.touched"
6386
+ [attr.aria-describedby]="describedBy()"
6387
+ [placeholder]="placeholder || 'https://example.com'"
6388
+ [attr.disabled]="field.disabled ? true : null"
6389
+ />
6390
+ @if (errorMessage) {
6391
+ <span
6392
+ class="ngx-field__error"
6393
+ [attr.data-testid]="'field-' + field.id + '-error'"
6394
+ [id]="domId('-error')"
6395
+ role="alert"
6396
+ >{{ errorMessage }}</span
6397
+ >
6398
+ }
6399
+ }
6400
+ </div>
6401
+ `,
6402
+ }]
6403
+ }], propDecorators: { field: [{
6404
+ type: Input
6405
+ }], control: [{
6406
+ type: Input
6407
+ }], language: [{
6408
+ type: Input
6409
+ }], readonly: [{
6410
+ type: Input
6411
+ }], masked: [{
6412
+ type: Input
6413
+ }] } });
6414
+
6415
+ /**
6416
+ * Phone field: a `type="tel"` input, and a `tel:` link when read-only.
6417
+ *
6418
+ * The value is stored exactly as typed, spaces and brackets included. The number is shown back
6419
+ * to people far more often than it is dialled, and `+44 20 7946 0958` is easier to read than
6420
+ * its digits run together. Only the link target is stripped down to what a dialler needs.
6421
+ */
6422
+ class PhoneFieldComponent {
6423
+ /**
6424
+ * Unique to this component instance: an `array` renders the same field once per row, and a
6425
+ * DOM id may not repeat. See `field-dom-id.ts`.
6426
+ */
6427
+ instanceId = nextFieldInstanceId();
6428
+ domId(suffix = '') {
6429
+ return fieldDomId(this.field, this.instanceId, suffix);
6430
+ }
6431
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
6432
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
6433
+ messages = inject(ValidationMessagesService);
6434
+ field;
6435
+ control;
6436
+ language = 'en';
6437
+ readonly = false;
6438
+ masked = false;
6439
+ get label() {
6440
+ return resolveLabel(this.field?.label, this.language);
6441
+ }
6442
+ get placeholder() {
6443
+ return resolveLabel(this.field?.placeholder, this.language);
6444
+ }
6445
+ /** Author help text, shown under the control and named by `aria-describedby`. */
6446
+ get hint() {
6447
+ return resolveLabel(this.field?.hint, this.language);
6448
+ }
6449
+ /** `tel:` plus the digits and a leading `+`, or null for a value that is not a phone number. */
6450
+ get dialHref() {
6451
+ const value = this.control?.value;
6452
+ if (typeof value !== 'string' || !isValidPhone(value))
6453
+ return null;
6454
+ return `tel:${value.trim().startsWith('+') ? '+' : ''}${value.replace(/\D/g, '')}`;
6455
+ }
6456
+ /** The ids of whatever is describing this control right now. */
6457
+ describedBy() {
6458
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
6459
+ }
6460
+ get errorMessage() {
6461
+ if (!this.control?.errors || !this.control.touched)
6462
+ return '';
6463
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
6464
+ }
6465
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: PhoneFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6466
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: PhoneFieldComponent, isStandalone: true, selector: "ngx-phone-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
6467
+ <div
6468
+ class="ngx-field ngx-field--phone"
6469
+ [attr.data-testid]="'field-' + field.id"
6470
+ [attr.data-field-type]="field.type"
6471
+ [class.ngx-field--readonly]="readonly"
6472
+ [class.ngx-field--masked]="masked"
6473
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6474
+ >
6475
+ <div class="ngx-field__label-row">
6476
+ <label class="ngx-field__label" [attr.for]="domId()">
6477
+ {{ label }}
6478
+ @if (field.validators?.required) {
6479
+ <span class="ngx-field__req">*</span>
6480
+ }
6481
+ </label>
6482
+ @if (hint) {
6483
+ <span class="ngx-field__hint-wrap">
6484
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6485
+ <span
6486
+ class="ngx-field__hint"
6487
+ role="tooltip"
6488
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6489
+ [id]="domId('-hint')"
6490
+ >{{ hint }}</span
6491
+ >
6492
+ </span>
6493
+ }
6494
+ </div>
6495
+ @if (masked) {
6496
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6497
+ maskedText
6498
+ }}</span>
6499
+ } @else if (readonly) {
6500
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6501
+ @if (dialHref) {
6502
+ <a [href]="dialHref" class="ngx-field__link">{{ control.value }}</a>
6503
+ } @else {
6504
+ {{ control.value || '—' }}
6505
+ }
6506
+ </span>
6507
+ } @else {
6508
+ <input
6509
+ [id]="domId()"
6510
+ class="ngx-field__input"
6511
+ [attr.data-testid]="'field-' + field.id + '-input'"
6512
+ type="tel"
6513
+ inputmode="tel"
6514
+ autocomplete="tel"
6515
+ [formControl]="$any(control)"
6516
+ [attr.aria-invalid]="control.invalid && control.touched"
6517
+ [attr.aria-describedby]="describedBy()"
6518
+ [placeholder]="placeholder"
6519
+ [attr.disabled]="field.disabled ? true : null"
6520
+ />
6521
+ @if (errorMessage) {
6522
+ <span
6523
+ class="ngx-field__error"
6524
+ [attr.data-testid]="'field-' + field.id + '-error'"
6525
+ [id]="domId('-error')"
6526
+ role="alert"
6527
+ >{{ errorMessage }}</span
6528
+ >
6529
+ }
6530
+ }
6531
+ </div>
6532
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6533
+ }
6534
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: PhoneFieldComponent, decorators: [{
6535
+ type: Component,
6536
+ args: [{
6537
+ changeDetection: ChangeDetectionStrategy.OnPush,
6538
+ selector: 'ngx-phone-field',
6539
+ standalone: true,
6540
+ imports: [ReactiveFormsModule],
6541
+ template: `
6542
+ <div
6543
+ class="ngx-field ngx-field--phone"
6544
+ [attr.data-testid]="'field-' + field.id"
6545
+ [attr.data-field-type]="field.type"
6546
+ [class.ngx-field--readonly]="readonly"
6547
+ [class.ngx-field--masked]="masked"
6548
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6549
+ >
6550
+ <div class="ngx-field__label-row">
6551
+ <label class="ngx-field__label" [attr.for]="domId()">
6552
+ {{ label }}
6553
+ @if (field.validators?.required) {
6554
+ <span class="ngx-field__req">*</span>
6555
+ }
6556
+ </label>
6557
+ @if (hint) {
6558
+ <span class="ngx-field__hint-wrap">
6559
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6560
+ <span
6561
+ class="ngx-field__hint"
6562
+ role="tooltip"
6563
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6564
+ [id]="domId('-hint')"
6565
+ >{{ hint }}</span
6566
+ >
6567
+ </span>
6568
+ }
6569
+ </div>
6570
+ @if (masked) {
6571
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6572
+ maskedText
6573
+ }}</span>
6574
+ } @else if (readonly) {
6575
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6576
+ @if (dialHref) {
6577
+ <a [href]="dialHref" class="ngx-field__link">{{ control.value }}</a>
6578
+ } @else {
6579
+ {{ control.value || '—' }}
6580
+ }
6581
+ </span>
6582
+ } @else {
6583
+ <input
6584
+ [id]="domId()"
6585
+ class="ngx-field__input"
6586
+ [attr.data-testid]="'field-' + field.id + '-input'"
6587
+ type="tel"
6588
+ inputmode="tel"
6589
+ autocomplete="tel"
6590
+ [formControl]="$any(control)"
6591
+ [attr.aria-invalid]="control.invalid && control.touched"
6592
+ [attr.aria-describedby]="describedBy()"
6593
+ [placeholder]="placeholder"
6594
+ [attr.disabled]="field.disabled ? true : null"
6595
+ />
6596
+ @if (errorMessage) {
6597
+ <span
6598
+ class="ngx-field__error"
6599
+ [attr.data-testid]="'field-' + field.id + '-error'"
6600
+ [id]="domId('-error')"
6601
+ role="alert"
6602
+ >{{ errorMessage }}</span
6603
+ >
6604
+ }
6605
+ }
6606
+ </div>
6607
+ `,
6608
+ }]
6609
+ }], propDecorators: { field: [{
6610
+ type: Input
6611
+ }], control: [{
6612
+ type: Input
6613
+ }], language: [{
6614
+ type: Input
6615
+ }], readonly: [{
6616
+ type: Input
6617
+ }], masked: [{
6618
+ type: Input
6619
+ }] } });
6620
+
6621
+ /**
6622
+ * Slider field: a range input over `validators.min`–`validators.max` in steps of `field.step`.
6623
+ *
6624
+ * A range input cannot show that it is empty. With no value it puts the thumb at the midpoint,
6625
+ * which looks exactly like a chosen value. The readout beside it is therefore the value itself,
6626
+ * and shows a dash until someone moves the thumb. Without it, a required slider nobody touched
6627
+ * would look answered and still fail at save.
6628
+ */
6629
+ class SliderFieldComponent {
6630
+ /**
6631
+ * Unique to this component instance: an `array` renders the same field once per row, and a
6632
+ * DOM id may not repeat. See `field-dom-id.ts`.
6633
+ */
6634
+ instanceId = nextFieldInstanceId();
6635
+ domId(suffix = '') {
6636
+ return fieldDomId(this.field, this.instanceId, suffix);
6637
+ }
6638
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
6639
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
6640
+ messages = inject(ValidationMessagesService);
6641
+ field;
6642
+ control;
6643
+ language = 'en';
6644
+ readonly = false;
6645
+ masked = false;
6646
+ get label() {
6647
+ return resolveLabel(this.field?.label, this.language);
6648
+ }
6649
+ /** Author help text, shown under the control and named by `aria-describedby`. */
6650
+ get hint() {
6651
+ return resolveLabel(this.field?.hint, this.language);
6652
+ }
6653
+ get bounds() {
6654
+ return sliderBounds(this.field);
6655
+ }
6656
+ /** Whether the control holds a number. The thumb's position cannot say. */
6657
+ get hasValue() {
6658
+ const value = this.control?.value;
6659
+ return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value));
6660
+ }
6661
+ get display() {
6662
+ return this.hasValue ? String(this.control.value) : '—';
6663
+ }
6664
+ /** The ids of whatever is describing this control right now. */
6665
+ describedBy() {
6666
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
6667
+ }
6668
+ get errorMessage() {
6669
+ if (!this.control?.errors || !this.control.touched)
6670
+ return '';
6671
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
6672
+ }
6673
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SliderFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6674
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SliderFieldComponent, isStandalone: true, selector: "ngx-slider-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
6675
+ <div
6676
+ class="ngx-field ngx-field--slider"
6677
+ [attr.data-testid]="'field-' + field.id"
6678
+ [attr.data-field-type]="field.type"
6679
+ [class.ngx-field--readonly]="readonly"
6680
+ [class.ngx-field--masked]="masked"
6681
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6682
+ >
6683
+ <div class="ngx-field__label-row">
6684
+ <label class="ngx-field__label" [attr.for]="domId()">
6685
+ {{ label }}
6686
+ @if (field.validators?.required) {
6687
+ <span class="ngx-field__req">*</span>
6688
+ }
6689
+ </label>
6690
+ @if (hint) {
6691
+ <span class="ngx-field__hint-wrap">
6692
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6693
+ <span
6694
+ class="ngx-field__hint"
6695
+ role="tooltip"
6696
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6697
+ [id]="domId('-hint')"
6698
+ >{{ hint }}</span
6699
+ >
6700
+ </span>
6701
+ }
6702
+ </div>
6703
+ @if (masked) {
6704
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6705
+ maskedText
6706
+ }}</span>
6707
+ } @else if (readonly) {
6708
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ display }}</span>
6709
+ } @else {
6710
+ <div class="ngx-field__slider-wrap">
6711
+ <input
6712
+ [id]="domId()"
6713
+ type="range"
6714
+ class="ngx-field__slider"
6715
+ [class.ngx-field__slider--unset]="!hasValue"
6716
+ [attr.data-testid]="'field-' + field.id + '-input'"
6717
+ [min]="bounds.min"
6718
+ [max]="bounds.max"
6719
+ [step]="bounds.step"
6720
+ [formControl]="$any(control)"
6721
+ [attr.aria-valuetext]="hasValue ? null : display"
6722
+ [attr.aria-invalid]="control.invalid && control.touched"
6723
+ [attr.aria-describedby]="describedBy()"
6724
+ [attr.disabled]="field.disabled ? true : null"
6725
+ />
6726
+ <output
6727
+ class="ngx-field__slider-value"
6728
+ [attr.for]="domId()"
6729
+ [attr.data-testid]="'field-' + field.id + '-readout'"
6730
+ aria-hidden="true"
6731
+ >{{ display }}</output
6732
+ >
6733
+ </div>
6734
+ @if (errorMessage) {
6735
+ <span
6736
+ class="ngx-field__error"
6737
+ [attr.data-testid]="'field-' + field.id + '-error'"
6738
+ [id]="domId('-error')"
6739
+ role="alert"
6740
+ >{{ errorMessage }}</span
6741
+ >
6742
+ }
6743
+ }
6744
+ </div>
6745
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RangeValueAccessor, selector: "input[type=range][formControlName],input[type=range][formControl],input[type=range][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6746
+ }
6747
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SliderFieldComponent, decorators: [{
6748
+ type: Component,
6749
+ args: [{
6750
+ changeDetection: ChangeDetectionStrategy.OnPush,
6751
+ selector: 'ngx-slider-field',
6752
+ standalone: true,
6753
+ imports: [ReactiveFormsModule],
6754
+ template: `
6755
+ <div
6756
+ class="ngx-field ngx-field--slider"
6757
+ [attr.data-testid]="'field-' + field.id"
6758
+ [attr.data-field-type]="field.type"
6759
+ [class.ngx-field--readonly]="readonly"
6760
+ [class.ngx-field--masked]="masked"
6761
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6762
+ >
6763
+ <div class="ngx-field__label-row">
6764
+ <label class="ngx-field__label" [attr.for]="domId()">
6765
+ {{ label }}
6766
+ @if (field.validators?.required) {
6767
+ <span class="ngx-field__req">*</span>
6768
+ }
6769
+ </label>
6770
+ @if (hint) {
6771
+ <span class="ngx-field__hint-wrap">
6772
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6773
+ <span
6774
+ class="ngx-field__hint"
6775
+ role="tooltip"
6776
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6777
+ [id]="domId('-hint')"
6778
+ >{{ hint }}</span
6779
+ >
6780
+ </span>
6781
+ }
6782
+ </div>
6783
+ @if (masked) {
6784
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6785
+ maskedText
6786
+ }}</span>
6787
+ } @else if (readonly) {
6788
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ display }}</span>
6789
+ } @else {
6790
+ <div class="ngx-field__slider-wrap">
6791
+ <input
6792
+ [id]="domId()"
6793
+ type="range"
6794
+ class="ngx-field__slider"
6795
+ [class.ngx-field__slider--unset]="!hasValue"
6796
+ [attr.data-testid]="'field-' + field.id + '-input'"
6797
+ [min]="bounds.min"
6798
+ [max]="bounds.max"
6799
+ [step]="bounds.step"
6800
+ [formControl]="$any(control)"
6801
+ [attr.aria-valuetext]="hasValue ? null : display"
6802
+ [attr.aria-invalid]="control.invalid && control.touched"
6803
+ [attr.aria-describedby]="describedBy()"
6804
+ [attr.disabled]="field.disabled ? true : null"
6805
+ />
6806
+ <output
6807
+ class="ngx-field__slider-value"
6808
+ [attr.for]="domId()"
6809
+ [attr.data-testid]="'field-' + field.id + '-readout'"
6810
+ aria-hidden="true"
6811
+ >{{ display }}</output
6812
+ >
6813
+ </div>
6814
+ @if (errorMessage) {
6815
+ <span
6816
+ class="ngx-field__error"
6817
+ [attr.data-testid]="'field-' + field.id + '-error'"
6818
+ [id]="domId('-error')"
6819
+ role="alert"
6820
+ >{{ errorMessage }}</span
6821
+ >
6822
+ }
6823
+ }
6824
+ </div>
6825
+ `,
6826
+ }]
6827
+ }], propDecorators: { field: [{
6828
+ type: Input
6829
+ }], control: [{
6830
+ type: Input
6831
+ }], language: [{
6832
+ type: Input
6833
+ }], readonly: [{
6834
+ type: Input
6835
+ }], masked: [{
6836
+ type: Input
6837
+ }] } });
6838
+
6839
+ /**
6840
+ * Rating field: 1 to `validators.max` stars (default 5), stored as a number.
6841
+ *
6842
+ * Built from native radio inputs in a fieldset, not clickable spans. That gives it the
6843
+ * behaviour a radio group already has: arrow keys move the choice, Tab enters and leaves the
6844
+ * group once, and a screen reader announces "3 of 5, radio button, 3 of 5". Each star's
6845
+ * label is text a host can translate (`ratingValue`), because a star glyph read aloud is
6846
+ * "black star".
6847
+ */
6848
+ class RatingFieldComponent {
6849
+ /**
6850
+ * Unique to this component instance: an `array` renders the same field once per row, and a
6851
+ * DOM id may not repeat. See `field-dom-id.ts`.
6852
+ */
6853
+ instanceId = nextFieldInstanceId();
6854
+ domId(suffix = '') {
6855
+ return fieldDomId(this.field, this.instanceId, suffix);
6856
+ }
6857
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
6858
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
6859
+ messages = inject(ValidationMessagesService);
6860
+ cdr = inject(ChangeDetectorRef);
6861
+ ui = inject(UiTextService);
6862
+ field;
6863
+ language = 'en';
6864
+ readonly = false;
6865
+ masked = false;
6866
+ _control;
6867
+ _controlSub;
6868
+ /**
6869
+ * Subscribed because the radios are not bound through a form directive. A value patched in
6870
+ * from outside (a reset, an autoPatch) would otherwise leave an OnPush view showing the old
6871
+ * stars.
6872
+ */
6873
+ set control(value) {
6874
+ this._control = value;
6875
+ this._controlSub?.unsubscribe();
6876
+ this._controlSub = this._control?.valueChanges?.subscribe(() => this.cdr.markForCheck());
6877
+ }
6878
+ get control() {
6879
+ return this._control;
6880
+ }
6881
+ ngOnDestroy() {
6882
+ this._controlSub?.unsubscribe();
6883
+ }
6884
+ get label() {
6885
+ return resolveLabel(this.field?.label, this.language);
6886
+ }
6887
+ /** Author help text, shown under the control and named by `aria-describedby`. */
6888
+ get hint() {
6889
+ return resolveLabel(this.field?.hint, this.language);
6890
+ }
6891
+ get scale() {
6892
+ return ratingScale(this.field);
6893
+ }
6894
+ get stars() {
6895
+ return Array.from({ length: this.scale }, (_, i) => i + 1);
6896
+ }
6897
+ /** The stored rating, or null when there is none or it is not a star on this scale. */
6898
+ get current() {
6899
+ const value = Number(this.control?.value);
6900
+ const raw = this.control?.value;
6901
+ if (raw === null || raw === undefined || raw === '' || !Number.isInteger(value))
6902
+ return null;
6903
+ return value >= 1 && value <= this.scale ? value : null;
6904
+ }
6905
+ select(star) {
6906
+ this.control?.setValue(star);
6907
+ this.control?.markAsTouched();
6908
+ this.control?.markAsDirty();
6909
+ }
6910
+ clear() {
6911
+ this.control?.setValue(null);
6912
+ this.control?.markAsTouched();
6913
+ this.control?.markAsDirty();
6914
+ }
6915
+ /** The ids of whatever is describing this control right now. */
6916
+ describedBy() {
6917
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
6918
+ }
6919
+ get errorMessage() {
6920
+ if (!this.control?.errors || !this.control.touched)
6921
+ return '';
6922
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
6923
+ }
6924
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RatingFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6925
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: RatingFieldComponent, isStandalone: true, selector: "ngx-rating-field", inputs: { field: "field", language: "language", readonly: "readonly", masked: "masked", control: "control" }, ngImport: i0, template: `
6926
+ <div
6927
+ class="ngx-field ngx-field--rating"
6928
+ [attr.data-testid]="'field-' + field.id"
6929
+ [attr.data-field-type]="field.type"
6930
+ [class.ngx-field--readonly]="readonly"
6931
+ [class.ngx-field--masked]="masked"
6932
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
6933
+ >
6934
+ <fieldset class="ngx-field__fieldset">
6935
+ <legend class="ngx-field__label">
6936
+ {{ label }}
6937
+ @if (field.validators?.required) {
6938
+ <span class="ngx-field__req">*</span>
6939
+ }
6940
+ @if (hint) {
6941
+ <!-- Inside the legend: a fieldset allows one legend, and it must come first. -->
6942
+ <span class="ngx-field__hint-wrap">
6943
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
6944
+ <span
6945
+ class="ngx-field__hint"
6946
+ role="tooltip"
6947
+ [attr.data-testid]="'field-' + field.id + '-hint'"
6948
+ [id]="domId('-hint')"
6949
+ >{{ hint }}</span
6950
+ >
6951
+ </span>
6952
+ }
6953
+ </legend>
6954
+ @if (masked) {
6955
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
6956
+ maskedText
6957
+ }}</span>
6958
+ } @else if (readonly) {
6959
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
6960
+ @if (current) {
6961
+ <span class="ngx-field__stars" aria-hidden="true">
6962
+ @for (star of stars; track star) {
6963
+ <span class="ngx-field__star-glyph" [class.ngx-field__star-glyph--on]="star <= current">{{
6964
+ star <= current ? '★' : '☆'
6965
+ }}</span>
6966
+ }
6967
+ </span>
6968
+ {{ ui.text('ratingValue', language, { value: current, max: scale }) }}
6969
+ } @else {
6970
+ —
6971
+ }
6972
+ </span>
6973
+ } @else {
6974
+ <div class="ngx-field__rating">
6975
+ @for (star of stars; track star) {
6976
+ <label class="ngx-field__star" [class.ngx-field__star--on]="current !== null && star <= current">
6977
+ <input
6978
+ type="radio"
6979
+ class="ngx-field__star-input"
6980
+ [id]="domId('-' + star)"
6981
+ [name]="domId()"
6982
+ [value]="star"
6983
+ [checked]="current === star"
6984
+ [attr.data-testid]="'field-' + field.id + '-star-' + star"
6985
+ [attr.aria-describedby]="describedBy()"
6986
+ [attr.disabled]="field.disabled ? true : null"
6987
+ (change)="select(star)"
6988
+ />
6989
+ <!-- Filled and hollow, so the rating is readable without telling colours apart. -->
6990
+ <span class="ngx-field__star-glyph" aria-hidden="true">{{
6991
+ current !== null && star <= current ? '★' : '☆'
6992
+ }}</span>
6993
+ <span class="ngx-field__sr-only">{{ ui.text('ratingValue', language, { value: star, max: scale }) }}</span>
6994
+ </label>
6995
+ }
6996
+ @if (current !== null && !field.disabled) {
6997
+ <button
6998
+ type="button"
6999
+ class="ngx-field__remove-btn"
7000
+ [attr.data-testid]="'field-' + field.id + '-clear'"
7001
+ [attr.aria-label]="ui.text('clearValue', language, { field: label })"
7002
+ (click)="clear()"
7003
+ >
7004
+ ×
7005
+ </button>
7006
+ }
7007
+ </div>
7008
+ @if (errorMessage) {
7009
+ <span
7010
+ class="ngx-field__error"
7011
+ [attr.data-testid]="'field-' + field.id + '-error'"
7012
+ [id]="domId('-error')"
7013
+ role="alert"
7014
+ >{{ errorMessage }}</span
7015
+ >
7016
+ }
7017
+ }
7018
+ </fieldset>
7019
+ </div>
7020
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7021
+ }
7022
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: RatingFieldComponent, decorators: [{
7023
+ type: Component,
7024
+ args: [{
7025
+ changeDetection: ChangeDetectionStrategy.OnPush,
7026
+ selector: 'ngx-rating-field',
7027
+ standalone: true,
7028
+ imports: [ReactiveFormsModule],
7029
+ template: `
7030
+ <div
7031
+ class="ngx-field ngx-field--rating"
7032
+ [attr.data-testid]="'field-' + field.id"
7033
+ [attr.data-field-type]="field.type"
7034
+ [class.ngx-field--readonly]="readonly"
7035
+ [class.ngx-field--masked]="masked"
7036
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7037
+ >
7038
+ <fieldset class="ngx-field__fieldset">
7039
+ <legend class="ngx-field__label">
7040
+ {{ label }}
7041
+ @if (field.validators?.required) {
7042
+ <span class="ngx-field__req">*</span>
7043
+ }
7044
+ @if (hint) {
7045
+ <!-- Inside the legend: a fieldset allows one legend, and it must come first. -->
7046
+ <span class="ngx-field__hint-wrap">
7047
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7048
+ <span
7049
+ class="ngx-field__hint"
7050
+ role="tooltip"
7051
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7052
+ [id]="domId('-hint')"
7053
+ >{{ hint }}</span
7054
+ >
7055
+ </span>
7056
+ }
7057
+ </legend>
7058
+ @if (masked) {
7059
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7060
+ maskedText
7061
+ }}</span>
7062
+ } @else if (readonly) {
7063
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
7064
+ @if (current) {
7065
+ <span class="ngx-field__stars" aria-hidden="true">
7066
+ @for (star of stars; track star) {
7067
+ <span class="ngx-field__star-glyph" [class.ngx-field__star-glyph--on]="star <= current">{{
7068
+ star <= current ? '★' : '☆'
7069
+ }}</span>
7070
+ }
7071
+ </span>
7072
+ {{ ui.text('ratingValue', language, { value: current, max: scale }) }}
7073
+ } @else {
7074
+ —
7075
+ }
7076
+ </span>
7077
+ } @else {
7078
+ <div class="ngx-field__rating">
7079
+ @for (star of stars; track star) {
7080
+ <label class="ngx-field__star" [class.ngx-field__star--on]="current !== null && star <= current">
7081
+ <input
7082
+ type="radio"
7083
+ class="ngx-field__star-input"
7084
+ [id]="domId('-' + star)"
7085
+ [name]="domId()"
7086
+ [value]="star"
7087
+ [checked]="current === star"
7088
+ [attr.data-testid]="'field-' + field.id + '-star-' + star"
7089
+ [attr.aria-describedby]="describedBy()"
7090
+ [attr.disabled]="field.disabled ? true : null"
7091
+ (change)="select(star)"
7092
+ />
7093
+ <!-- Filled and hollow, so the rating is readable without telling colours apart. -->
7094
+ <span class="ngx-field__star-glyph" aria-hidden="true">{{
7095
+ current !== null && star <= current ? '★' : '☆'
7096
+ }}</span>
7097
+ <span class="ngx-field__sr-only">{{ ui.text('ratingValue', language, { value: star, max: scale }) }}</span>
7098
+ </label>
7099
+ }
7100
+ @if (current !== null && !field.disabled) {
7101
+ <button
7102
+ type="button"
7103
+ class="ngx-field__remove-btn"
7104
+ [attr.data-testid]="'field-' + field.id + '-clear'"
7105
+ [attr.aria-label]="ui.text('clearValue', language, { field: label })"
7106
+ (click)="clear()"
7107
+ >
7108
+ ×
7109
+ </button>
7110
+ }
7111
+ </div>
7112
+ @if (errorMessage) {
7113
+ <span
7114
+ class="ngx-field__error"
7115
+ [attr.data-testid]="'field-' + field.id + '-error'"
7116
+ [id]="domId('-error')"
7117
+ role="alert"
7118
+ >{{ errorMessage }}</span
7119
+ >
7120
+ }
7121
+ }
7122
+ </fieldset>
7123
+ </div>
7124
+ `,
7125
+ }]
7126
+ }], propDecorators: { field: [{
7127
+ type: Input
7128
+ }], language: [{
7129
+ type: Input
7130
+ }], readonly: [{
7131
+ type: Input
7132
+ }], masked: [{
7133
+ type: Input
7134
+ }], control: [{
7135
+ type: Input
7136
+ }] } });
7137
+
7138
+ /**
7139
+ * Colour field: a native colour picker, storing `#rrggbb`.
7140
+ *
7141
+ * Like a range input, a colour input has no empty state and shows black. The code beside it is
7142
+ * therefore the real value, a dash until a colour is chosen, and a clear button sets the value
7143
+ * back to nothing. Without them, "no colour" and "black" would look the same.
7144
+ *
7145
+ * Only a value that passes `normalizeHexColor` reaches a `style` binding. A record can hold
7146
+ * any string, and a colour swatch is no place to interpret one.
7147
+ */
7148
+ class ColorFieldComponent {
7149
+ /**
7150
+ * Unique to this component instance: an `array` renders the same field once per row, and a
7151
+ * DOM id may not repeat. See `field-dom-id.ts`.
7152
+ */
7153
+ instanceId = nextFieldInstanceId();
7154
+ domId(suffix = '') {
7155
+ return fieldDomId(this.field, this.instanceId, suffix);
7156
+ }
7157
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
7158
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
7159
+ messages = inject(ValidationMessagesService);
7160
+ ui = inject(UiTextService);
7161
+ field;
7162
+ control;
7163
+ language = 'en';
7164
+ readonly = false;
7165
+ masked = false;
7166
+ get label() {
7167
+ return resolveLabel(this.field?.label, this.language);
7168
+ }
7169
+ /** Author help text, shown under the control and named by `aria-describedby`. */
7170
+ get hint() {
7171
+ return resolveLabel(this.field?.hint, this.language);
7172
+ }
7173
+ /** The stored colour as `#rrggbb`, or null when there is none or it is not a colour. */
7174
+ get hex() {
7175
+ return normalizeHexColor(this.control?.value);
7176
+ }
7177
+ clear() {
7178
+ this.control?.setValue(null);
7179
+ this.control?.markAsTouched();
7180
+ this.control?.markAsDirty();
7181
+ }
7182
+ /** The ids of whatever is describing this control right now. */
7183
+ describedBy() {
7184
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
7185
+ }
7186
+ get errorMessage() {
7187
+ if (!this.control?.errors || !this.control.touched)
7188
+ return '';
7189
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
7190
+ }
7191
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ColorFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
7192
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: ColorFieldComponent, isStandalone: true, selector: "ngx-color-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
7193
+ <div
7194
+ class="ngx-field ngx-field--color"
7195
+ [attr.data-testid]="'field-' + field.id"
7196
+ [attr.data-field-type]="field.type"
7197
+ [class.ngx-field--readonly]="readonly"
7198
+ [class.ngx-field--masked]="masked"
7199
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7200
+ >
7201
+ <div class="ngx-field__label-row">
7202
+ <label class="ngx-field__label" [attr.for]="domId()">
7203
+ {{ label }}
7204
+ @if (field.validators?.required) {
7205
+ <span class="ngx-field__req">*</span>
7206
+ }
7207
+ </label>
7208
+ @if (hint) {
7209
+ <span class="ngx-field__hint-wrap">
7210
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7211
+ <span
7212
+ class="ngx-field__hint"
7213
+ role="tooltip"
7214
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7215
+ [id]="domId('-hint')"
7216
+ >{{ hint }}</span
7217
+ >
7218
+ </span>
7219
+ }
7220
+ </div>
7221
+ @if (masked) {
7222
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7223
+ maskedText
7224
+ }}</span>
7225
+ } @else if (readonly) {
7226
+ <span class="ngx-field__value ngx-field__color-value" [attr.data-testid]="'field-' + field.id + '-value'">
7227
+ @if (hex) {
7228
+ <span class="ngx-field__swatch" [style.background-color]="hex" aria-hidden="true"></span>
7229
+ {{ hex }}
7230
+ } @else {
7231
+ {{ control.value || '—' }}
7232
+ }
7233
+ </span>
7234
+ } @else {
7235
+ <div class="ngx-field__color-wrap">
7236
+ <input
7237
+ [id]="domId()"
7238
+ type="color"
7239
+ class="ngx-field__color"
7240
+ [attr.data-testid]="'field-' + field.id + '-input'"
7241
+ [formControl]="$any(control)"
7242
+ [attr.aria-invalid]="control.invalid && control.touched"
7243
+ [attr.aria-describedby]="describedBy()"
7244
+ [attr.disabled]="field.disabled ? true : null"
7245
+ />
7246
+ <span class="ngx-field__color-code" [attr.data-testid]="'field-' + field.id + '-code'">{{ hex || '—' }}</span>
7247
+ @if (hex && !field.disabled) {
7248
+ <button
7249
+ type="button"
7250
+ class="ngx-field__remove-btn"
7251
+ [attr.data-testid]="'field-' + field.id + '-clear'"
7252
+ [attr.aria-label]="ui.text('clearValue', language, { field: label })"
7253
+ (click)="clear()"
7254
+ >
7255
+ ×
7256
+ </button>
7257
+ }
7258
+ </div>
7259
+ @if (errorMessage) {
7260
+ <span
7261
+ class="ngx-field__error"
7262
+ [attr.data-testid]="'field-' + field.id + '-error'"
7263
+ [id]="domId('-error')"
7264
+ role="alert"
7265
+ >{{ errorMessage }}</span
7266
+ >
7267
+ }
7268
+ }
7269
+ </div>
7270
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7271
+ }
7272
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: ColorFieldComponent, decorators: [{
7273
+ type: Component,
7274
+ args: [{
7275
+ changeDetection: ChangeDetectionStrategy.OnPush,
7276
+ selector: 'ngx-color-field',
7277
+ standalone: true,
7278
+ imports: [ReactiveFormsModule],
7279
+ template: `
7280
+ <div
7281
+ class="ngx-field ngx-field--color"
7282
+ [attr.data-testid]="'field-' + field.id"
7283
+ [attr.data-field-type]="field.type"
7284
+ [class.ngx-field--readonly]="readonly"
7285
+ [class.ngx-field--masked]="masked"
7286
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7287
+ >
7288
+ <div class="ngx-field__label-row">
7289
+ <label class="ngx-field__label" [attr.for]="domId()">
7290
+ {{ label }}
7291
+ @if (field.validators?.required) {
7292
+ <span class="ngx-field__req">*</span>
7293
+ }
7294
+ </label>
7295
+ @if (hint) {
7296
+ <span class="ngx-field__hint-wrap">
7297
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7298
+ <span
7299
+ class="ngx-field__hint"
7300
+ role="tooltip"
7301
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7302
+ [id]="domId('-hint')"
7303
+ >{{ hint }}</span
7304
+ >
7305
+ </span>
7306
+ }
7307
+ </div>
7308
+ @if (masked) {
7309
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7310
+ maskedText
7311
+ }}</span>
7312
+ } @else if (readonly) {
7313
+ <span class="ngx-field__value ngx-field__color-value" [attr.data-testid]="'field-' + field.id + '-value'">
7314
+ @if (hex) {
7315
+ <span class="ngx-field__swatch" [style.background-color]="hex" aria-hidden="true"></span>
7316
+ {{ hex }}
7317
+ } @else {
7318
+ {{ control.value || '—' }}
7319
+ }
7320
+ </span>
7321
+ } @else {
7322
+ <div class="ngx-field__color-wrap">
7323
+ <input
7324
+ [id]="domId()"
7325
+ type="color"
7326
+ class="ngx-field__color"
7327
+ [attr.data-testid]="'field-' + field.id + '-input'"
7328
+ [formControl]="$any(control)"
7329
+ [attr.aria-invalid]="control.invalid && control.touched"
7330
+ [attr.aria-describedby]="describedBy()"
7331
+ [attr.disabled]="field.disabled ? true : null"
7332
+ />
7333
+ <span class="ngx-field__color-code" [attr.data-testid]="'field-' + field.id + '-code'">{{ hex || '—' }}</span>
7334
+ @if (hex && !field.disabled) {
7335
+ <button
7336
+ type="button"
7337
+ class="ngx-field__remove-btn"
7338
+ [attr.data-testid]="'field-' + field.id + '-clear'"
7339
+ [attr.aria-label]="ui.text('clearValue', language, { field: label })"
7340
+ (click)="clear()"
7341
+ >
7342
+ ×
7343
+ </button>
7344
+ }
7345
+ </div>
7346
+ @if (errorMessage) {
7347
+ <span
7348
+ class="ngx-field__error"
7349
+ [attr.data-testid]="'field-' + field.id + '-error'"
7350
+ [id]="domId('-error')"
7351
+ role="alert"
7352
+ >{{ errorMessage }}</span
7353
+ >
7354
+ }
7355
+ }
7356
+ </div>
7357
+ `,
7358
+ }]
7359
+ }], propDecorators: { field: [{
7360
+ type: Input
7361
+ }], control: [{
7362
+ type: Input
7363
+ }], language: [{
7364
+ type: Input
7365
+ }], readonly: [{
7366
+ type: Input
7367
+ }], masked: [{
7368
+ type: Input
7369
+ }] } });
7370
+
7371
+ /** What ends a tag while typing, or separates several in pasted text. */
7372
+ const TAG_SEPARATOR = /[,;\n]/;
7373
+ /**
7374
+ * Tags field: free text entered as a list of chips, stored as `string[]`.
7375
+ *
7376
+ * Unlike `multiSelect`, the values are whatever the user types, so there is no option list to
7377
+ * keep in step. Enter or a comma ends a tag, Backspace in an empty box removes the last one,
7378
+ * and pasted text is split on commas, semicolons and line breaks. Leaving the box commits
7379
+ * what is in it, so a tag someone typed and then tabbed away from is not silently lost.
7380
+ *
7381
+ * The control holds the array. The text box is only a draft, which is why it is not bound
7382
+ * with a form directive.
7383
+ */
7384
+ class TagsFieldComponent {
7385
+ /**
7386
+ * Unique to this component instance: an `array` renders the same field once per row, and a
7387
+ * DOM id may not repeat. See `field-dom-id.ts`.
7388
+ */
7389
+ instanceId = nextFieldInstanceId();
7390
+ domId(suffix = '') {
7391
+ return fieldDomId(this.field, this.instanceId, suffix);
7392
+ }
7393
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
7394
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
7395
+ messages = inject(ValidationMessagesService);
7396
+ cdr = inject(ChangeDetectorRef);
7397
+ ui = inject(UiTextService);
7398
+ field;
7399
+ language = 'en';
7400
+ readonly = false;
7401
+ masked = false;
7402
+ _control;
7403
+ _controlSub;
7404
+ /** Subscribed for the same reason as the rating field: nothing here is a form directive. */
7405
+ set control(value) {
7406
+ this._control = value;
7407
+ this._controlSub?.unsubscribe();
7408
+ this._controlSub = this._control?.valueChanges?.subscribe(() => this.cdr.markForCheck());
7409
+ }
7410
+ get control() {
7411
+ return this._control;
7412
+ }
7413
+ ngOnDestroy() {
7414
+ this._controlSub?.unsubscribe();
7415
+ }
7416
+ get label() {
7417
+ return resolveLabel(this.field?.label, this.language);
7418
+ }
7419
+ get placeholder() {
7420
+ return resolveLabel(this.field?.placeholder, this.language);
7421
+ }
7422
+ /** Author help text, shown under the control and named by `aria-describedby`. */
7423
+ get hint() {
7424
+ return resolveLabel(this.field?.hint, this.language);
7425
+ }
7426
+ /** The stored tags, read leniently. A record may hold a single string or anything else. */
7427
+ get tags() {
7428
+ return normalizeTags(this.control?.value);
7429
+ }
7430
+ onKeydown(event, entry) {
7431
+ if (event.key === 'Enter' || event.key === ',' || event.key === ';') {
7432
+ // Enter inside a form submits it. Adding a tag is what the user meant.
7433
+ event.preventDefault();
7434
+ this.commit(entry);
7435
+ }
7436
+ else if (event.key === 'Backspace' && entry.value === '' && this.tags.length) {
7437
+ this.remove(this.tags[this.tags.length - 1]);
7438
+ }
7439
+ }
7440
+ onPaste(event, entry) {
7441
+ const text = event.clipboardData?.getData('text') ?? '';
7442
+ if (!TAG_SEPARATOR.test(text))
7443
+ return;
7444
+ event.preventDefault();
7445
+ // Split here, not by writing the text into the box first: a single-line input strips line
7446
+ // breaks on assignment, which would join "z\nw" into one tag.
7447
+ const draft = entry.value;
7448
+ entry.value = '';
7449
+ this.add([draft, ...text.split(TAG_SEPARATOR)]);
7450
+ }
7451
+ onBlur(entry) {
7452
+ this.commit(entry);
7453
+ this.control?.markAsTouched();
7454
+ }
7455
+ /** Add whatever is in the draft box, split on separators, and empty it. */
7456
+ commit(entry) {
7457
+ const added = entry.value.split(TAG_SEPARATOR);
7458
+ entry.value = '';
7459
+ this.add(added);
7460
+ }
7461
+ add(candidates) {
7462
+ const next = normalizeTags([...this.tags, ...candidates]);
7463
+ if (next.length !== this.tags.length)
7464
+ this.write(next);
7465
+ }
7466
+ remove(tag) {
7467
+ this.write(this.tags.filter(t => t !== tag));
7468
+ }
7469
+ /**
7470
+ * An empty list is stored as `[]`, not `null`. `required` treats both as empty, and `[]`
7471
+ * stays the same shape as every other value this field holds.
7472
+ */
7473
+ write(tags) {
7474
+ this.control?.setValue(tags);
7475
+ this.control?.markAsTouched();
7476
+ this.control?.markAsDirty();
7477
+ }
7478
+ /** The ids of whatever is describing this control right now. */
7479
+ describedBy() {
7480
+ return fieldDescribedBy(s => this.domId(s), { hint: !!this.hint, error: !!this.errorMessage });
7481
+ }
7482
+ get errorMessage() {
7483
+ if (!this.control?.errors || !this.control.touched)
7484
+ return '';
7485
+ return this.messages.resolveForField(this.control.errors, this.language, this.field.type);
7486
+ }
7487
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: TagsFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
7488
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: TagsFieldComponent, isStandalone: true, selector: "ngx-tags-field", inputs: { field: "field", language: "language", readonly: "readonly", masked: "masked", control: "control" }, ngImport: i0, template: `
7489
+ <div
7490
+ class="ngx-field ngx-field--tags"
7491
+ [attr.data-testid]="'field-' + field.id"
7492
+ [attr.data-field-type]="field.type"
7493
+ [class.ngx-field--readonly]="readonly"
7494
+ [class.ngx-field--masked]="masked"
7495
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7496
+ >
7497
+ <div class="ngx-field__label-row">
7498
+ <label class="ngx-field__label" [attr.for]="domId()">
7499
+ {{ label }}
7500
+ @if (field.validators?.required) {
7501
+ <span class="ngx-field__req">*</span>
7502
+ }
7503
+ </label>
7504
+ @if (hint) {
7505
+ <span class="ngx-field__hint-wrap">
7506
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7507
+ <span
7508
+ class="ngx-field__hint"
7509
+ role="tooltip"
7510
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7511
+ [id]="domId('-hint')"
7512
+ >{{ hint }}</span
7513
+ >
7514
+ </span>
7515
+ }
7516
+ </div>
7517
+ @if (masked) {
7518
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7519
+ maskedText
7520
+ }}</span>
7521
+ } @else if (readonly) {
7522
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
7523
+ @if (tags.length) {
7524
+ <span class="ngx-field__tags ngx-field__tags--readonly">
7525
+ @for (tag of tags; track tag) {
7526
+ <span class="ngx-field__tag">{{ tag }}</span>
7527
+ }
7528
+ </span>
7529
+ } @else {
7530
+ —
7531
+ }
7532
+ </span>
7533
+ } @else {
7534
+ <div class="ngx-field__tags" [class.ngx-field__tags--disabled]="field.disabled">
7535
+ @for (tag of tags; track tag) {
7536
+ <span class="ngx-field__tag" [attr.data-testid]="'field-' + field.id + '-tag'">
7537
+ {{ tag }}
7538
+ @if (!field.disabled) {
7539
+ <button
7540
+ type="button"
7541
+ class="ngx-field__tag-remove"
7542
+ [attr.aria-label]="ui.text('removeTag', language, { tag: tag })"
7543
+ (click)="remove(tag)"
7544
+ >
7545
+ ×
7546
+ </button>
7547
+ }
7548
+ </span>
7549
+ }
7550
+ <input
7551
+ #entry
7552
+ [id]="domId()"
7553
+ type="text"
7554
+ class="ngx-field__tags-input"
7555
+ [attr.data-testid]="'field-' + field.id + '-input'"
7556
+ [attr.aria-invalid]="control.invalid && control.touched"
7557
+ [attr.aria-describedby]="describedBy()"
7558
+ [placeholder]="placeholder || ui.text('addTagPlaceholder', language)"
7559
+ [attr.disabled]="field.disabled ? true : null"
7560
+ (keydown)="onKeydown($event, entry)"
7561
+ (paste)="onPaste($event, entry)"
7562
+ (blur)="onBlur(entry)"
7563
+ />
7564
+ </div>
7565
+ @if (errorMessage) {
7566
+ <span
7567
+ class="ngx-field__error"
7568
+ [attr.data-testid]="'field-' + field.id + '-error'"
7569
+ [id]="domId('-error')"
7570
+ role="alert"
7571
+ >{{ errorMessage }}</span
7572
+ >
7573
+ }
7574
+ }
7575
+ </div>
7576
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
7577
+ }
7578
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: TagsFieldComponent, decorators: [{
7579
+ type: Component,
7580
+ args: [{
7581
+ changeDetection: ChangeDetectionStrategy.OnPush,
7582
+ selector: 'ngx-tags-field',
7583
+ standalone: true,
7584
+ template: `
7585
+ <div
7586
+ class="ngx-field ngx-field--tags"
7587
+ [attr.data-testid]="'field-' + field.id"
7588
+ [attr.data-field-type]="field.type"
7589
+ [class.ngx-field--readonly]="readonly"
7590
+ [class.ngx-field--masked]="masked"
7591
+ [class.ngx-field--invalid]="control && control.invalid && control.touched"
7592
+ >
7593
+ <div class="ngx-field__label-row">
7594
+ <label class="ngx-field__label" [attr.for]="domId()">
7595
+ {{ label }}
7596
+ @if (field.validators?.required) {
7597
+ <span class="ngx-field__req">*</span>
7598
+ }
7599
+ </label>
7600
+ @if (hint) {
7601
+ <span class="ngx-field__hint-wrap">
7602
+ <span class="ngx-field__hint-icon" aria-hidden="true">i</span>
7603
+ <span
7604
+ class="ngx-field__hint"
7605
+ role="tooltip"
7606
+ [attr.data-testid]="'field-' + field.id + '-hint'"
7607
+ [id]="domId('-hint')"
7608
+ >{{ hint }}</span
7609
+ >
7610
+ </span>
7611
+ }
7612
+ </div>
7613
+ @if (masked) {
7614
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
7615
+ maskedText
7616
+ }}</span>
7617
+ } @else if (readonly) {
7618
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
7619
+ @if (tags.length) {
7620
+ <span class="ngx-field__tags ngx-field__tags--readonly">
7621
+ @for (tag of tags; track tag) {
7622
+ <span class="ngx-field__tag">{{ tag }}</span>
7623
+ }
7624
+ </span>
7625
+ } @else {
7626
+ —
7627
+ }
7628
+ </span>
7629
+ } @else {
7630
+ <div class="ngx-field__tags" [class.ngx-field__tags--disabled]="field.disabled">
7631
+ @for (tag of tags; track tag) {
7632
+ <span class="ngx-field__tag" [attr.data-testid]="'field-' + field.id + '-tag'">
7633
+ {{ tag }}
7634
+ @if (!field.disabled) {
7635
+ <button
7636
+ type="button"
7637
+ class="ngx-field__tag-remove"
7638
+ [attr.aria-label]="ui.text('removeTag', language, { tag: tag })"
7639
+ (click)="remove(tag)"
7640
+ >
7641
+ ×
7642
+ </button>
7643
+ }
7644
+ </span>
7645
+ }
7646
+ <input
7647
+ #entry
7648
+ [id]="domId()"
7649
+ type="text"
7650
+ class="ngx-field__tags-input"
7651
+ [attr.data-testid]="'field-' + field.id + '-input'"
7652
+ [attr.aria-invalid]="control.invalid && control.touched"
7653
+ [attr.aria-describedby]="describedBy()"
7654
+ [placeholder]="placeholder || ui.text('addTagPlaceholder', language)"
7655
+ [attr.disabled]="field.disabled ? true : null"
7656
+ (keydown)="onKeydown($event, entry)"
7657
+ (paste)="onPaste($event, entry)"
7658
+ (blur)="onBlur(entry)"
7659
+ />
7660
+ </div>
7661
+ @if (errorMessage) {
7662
+ <span
7663
+ class="ngx-field__error"
7664
+ [attr.data-testid]="'field-' + field.id + '-error'"
7665
+ [id]="domId('-error')"
7666
+ role="alert"
7667
+ >{{ errorMessage }}</span
7668
+ >
7669
+ }
7670
+ }
7671
+ </div>
7672
+ `,
7673
+ }]
7674
+ }], propDecorators: { field: [{
7675
+ type: Input
7676
+ }], language: [{
7677
+ type: Input
7678
+ }], readonly: [{
7679
+ type: Input
7680
+ }], masked: [{
7681
+ type: Input
7682
+ }], control: [{
7683
+ type: Input
7684
+ }] } });
7685
+
6154
7686
  /**
6155
7687
  * Field-type registration — the tree-shaking seam.
6156
7688
  *
6157
7689
  * `FieldRegistryService` imports no components. Whatever you register here is what gets
6158
7690
  * bundled, so an app that only uses text/number/dropdown pays for three components and
6159
- * their imports, not twenty-one.
7691
+ * their imports, not twenty-seven.
6160
7692
  *
6161
7693
  * ```ts
6162
7694
  * // Everything (convenient; bundles all built-ins):
@@ -6192,6 +7724,12 @@ function builtInFieldTypes() {
6192
7724
  array: ArrayFieldComponent,
6193
7725
  image: ImageFieldComponent,
6194
7726
  file: FileFieldComponent,
7727
+ url: UrlFieldComponent,
7728
+ phone: PhoneFieldComponent,
7729
+ slider: SliderFieldComponent,
7730
+ rating: RatingFieldComponent,
7731
+ color: ColorFieldComponent,
7732
+ tags: TagsFieldComponent,
6195
7733
  };
6196
7734
  }
6197
7735
  /** Register a specific set of field types. Only the components you name are bundled. */
@@ -6200,7 +7738,7 @@ function provideFieldTypes(types) {
6200
7738
  { provide: FIELD_TYPE_SETS, useValue: types, multi: true },
6201
7739
  ]);
6202
7740
  }
6203
- /** Register all 21 built-in field type keys. Convenience for apps that use most of them. */
7741
+ /** Register all 27 built-in field type keys. Convenience for apps that use most of them. */
6204
7742
  function provideBuiltInFieldTypes() {
6205
7743
  return provideFieldTypes(builtInFieldTypes());
6206
7744
  }
@@ -6716,6 +8254,12 @@ const AUTO_COL_SPAN = {
6716
8254
  boolean: 4,
6717
8255
  checkbox: 4,
6718
8256
  'entity-ref': 6,
8257
+ url: 6,
8258
+ phone: 6,
8259
+ slider: 6,
8260
+ rating: 4,
8261
+ color: 4,
8262
+ tags: 6,
6719
8263
  // Left at the full width deliberately — textarea, markdown, image, file, group and array
6720
8264
  // each hold something a half-row would crop.
6721
8265
  };
@@ -9972,5 +11516,5 @@ function saveBlob(blob, filename) {
9972
11516
  * Generated bundle index. Do not edit.
9973
11517
  */
9974
11518
 
9975
- export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityImportComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, HttpImportTransport, IMPORT_TRANSPORT, ImageFieldComponent, ImportErrorsComponent, ImportMapperComponent, ImportPreviewComponent, ImportTemplateComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LocalImportTransport, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SHEET_PARSER, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, defaultSheetParser, fieldDescribedBy, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideHttpImportTransport, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
11519
+ export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ColorFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityImportComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, HttpImportTransport, IMPORT_TRANSPORT, ImageFieldComponent, ImportErrorsComponent, ImportMapperComponent, ImportPreviewComponent, ImportTemplateComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LocalImportTransport, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, PhoneFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RatingFieldComponent, RbacService, RulesEvaluationService, SHEET_PARSER, SYSTEM_DEFAULT_CAN_EDIT, SliderFieldComponent, TagsFieldComponent, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, UrlFieldComponent, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, defaultSheetParser, fieldDescribedBy, fieldDomId, nextFieldInstanceId, phoneValidator, provideBuiltInFieldTypes, provideFieldTypes, provideHttpImportTransport, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText, urlValidator };
9976
11520
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map