@neuravision/ng-construct 0.1.0 → 0.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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, output, signal, computed, ChangeDetectionStrategy, Component, inject, forwardRef, model, booleanAttribute, viewChild, contentChildren, effect, ElementRef, ContentChild, TemplateRef, Directive, Injectable, viewChildren, Renderer2, InjectionToken, contentChild, DOCUMENT, numberAttribute, Pipe } from '@angular/core';
2
+ import { input, output, signal, computed, ChangeDetectionStrategy, Component, inject, forwardRef, model, booleanAttribute, viewChild, contentChildren, effect, contentChild, ElementRef, TemplateRef, Directive, Injectable, viewChildren, Renderer2, InjectionToken, DOCUMENT, numberAttribute, Pipe } from '@angular/core';
3
3
  import { NG_VALUE_ACCESSOR } from '@angular/forms';
4
4
  import { NgTemplateOutlet } from '@angular/common';
5
5
 
@@ -1200,36 +1200,65 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1200
1200
  `, styles: [":host{display:block}\n"] }]
1201
1201
  }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], indeterminate: [{ type: i0.Input, args: [{ isSignal: true, alias: "indeterminate", required: false }] }, { type: i0.Output, args: ["indeterminateChange"] }], checked: [{ type: i0.Input, args: [{ isSignal: true, alias: "checked", required: false }] }, { type: i0.Output, args: ["checkedChange"] }] } });
1202
1202
 
1203
+ // ── Radio Button ─────────────────────────────────────────────────────────────
1203
1204
  /**
1204
- * Radio button component with form control support
1205
+ * Individual radio button. Use inside `af-radio-group` for full
1206
+ * accessibility, or standalone with its own `ControlValueAccessor`.
1205
1207
  *
1206
1208
  * @example
1207
- * <af-radio name="plan" value="standard" [(ngModel)]="selectedPlan">
1208
- * Standard
1209
- * </af-radio>
1210
- * <af-radio name="plan" value="premium" [(ngModel)]="selectedPlan">
1211
- * Premium
1212
- * </af-radio>
1209
+ * <af-radio-group ariaLabel="Plan" name="plan" [(ngModel)]="plan">
1210
+ * <af-radio value="standard">Standard</af-radio>
1211
+ * <af-radio value="premium">Premium</af-radio>
1212
+ * </af-radio-group>
1213
1213
  */
1214
1214
  class AfRadioComponent {
1215
- /** Radio group name */
1215
+ group = inject(forwardRef(() => AfRadioGroupComponent), { optional: true });
1216
+ /** Radio group name (only used without `af-radio-group`). */
1216
1217
  name = input('', ...(ngDevMode ? [{ debugName: "name" }] : []));
1217
- /** Radio value */
1218
+ /** Radio value. */
1218
1219
  value = input(undefined, ...(ngDevMode ? [{ debugName: "value" }] : []));
1219
- /** Whether radio is disabled */
1220
+ /** Whether this radio is disabled. */
1220
1221
  disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
1222
+ inputRef = viewChild.required('inputEl');
1221
1223
  modelValue = signal(undefined, ...(ngDevMode ? [{ debugName: "modelValue" }] : []));
1222
1224
  onChangeCallback = () => { };
1223
1225
  onTouched = () => { };
1224
- isChecked = computed(() => this.modelValue() === this.value(), ...(ngDevMode ? [{ debugName: "isChecked" }] : []));
1225
- onChangeEvent(event) {
1226
- const target = event.target;
1227
- if (target.checked) {
1226
+ resolvedName = computed(() => this.group?.name() || this.name(), ...(ngDevMode ? [{ debugName: "resolvedName" }] : []));
1227
+ isChecked = computed(() => {
1228
+ const selected = this.group ? this.group.selectedValue() : this.modelValue();
1229
+ return selected === this.value();
1230
+ }, ...(ngDevMode ? [{ debugName: "isChecked" }] : []));
1231
+ isDisabled = computed(() => {
1232
+ if (this.group?.disabled())
1233
+ return true;
1234
+ return this.disabled();
1235
+ }, ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
1236
+ resolvedTabindex = computed(() => {
1237
+ if (!this.group)
1238
+ return null;
1239
+ return this.group.tabindexFor(this);
1240
+ }, ...(ngDevMode ? [{ debugName: "resolvedTabindex" }] : []));
1241
+ /** Focuses the native input element. */
1242
+ focus() {
1243
+ this.inputRef().nativeElement.focus();
1244
+ }
1245
+ onChangeEvent() {
1246
+ if (this.group) {
1247
+ this.group.selectRadio(this);
1248
+ }
1249
+ else {
1228
1250
  this.modelValue.set(this.value());
1229
1251
  this.onChangeCallback(this.value());
1230
1252
  }
1231
1253
  }
1232
- /** ControlValueAccessor implementation */
1254
+ onFocus() {
1255
+ this.group?.onRadioFocus(this);
1256
+ }
1257
+ onKeydown(event) {
1258
+ if (this.group) {
1259
+ this.group.onRadioKeydown(event, this);
1260
+ }
1261
+ }
1233
1262
  writeValue(value) {
1234
1263
  this.modelValue.set(value);
1235
1264
  }
@@ -1243,26 +1272,29 @@ class AfRadioComponent {
1243
1272
  this.disabled.set(isDisabled);
1244
1273
  }
1245
1274
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfRadioComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1246
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.2", type: AfRadioComponent, isStandalone: true, selector: "af-radio", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange" }, providers: [
1275
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.2", type: AfRadioComponent, isStandalone: true, selector: "af-radio", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange" }, providers: [
1247
1276
  {
1248
1277
  provide: NG_VALUE_ACCESSOR,
1249
1278
  useExisting: forwardRef(() => AfRadioComponent),
1250
- multi: true
1251
- }
1252
- ], ngImport: i0, template: `
1279
+ multi: true,
1280
+ },
1281
+ ], viewQueries: [{ propertyName: "inputRef", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
1253
1282
  <label class="ct-radio">
1254
1283
  <input
1284
+ #inputEl
1255
1285
  class="ct-radio__input"
1256
1286
  type="radio"
1257
- [name]="name()"
1287
+ [name]="resolvedName()"
1258
1288
  [value]="value()"
1259
1289
  [checked]="isChecked()"
1260
- [disabled]="disabled()"
1261
- (change)="onChangeEvent($event)"
1290
+ [disabled]="isDisabled()"
1291
+ [attr.tabindex]="resolvedTabindex()"
1292
+ (change)="onChangeEvent()"
1262
1293
  (blur)="onTouched()"
1263
- />
1294
+ (focus)="onFocus()"
1295
+ (keydown)="onKeydown($event)" />
1264
1296
  <span class="ct-radio__label">
1265
- <ng-content></ng-content>
1297
+ <ng-content />
1266
1298
  </span>
1267
1299
  </label>
1268
1300
  `, isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
@@ -1273,26 +1305,172 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1273
1305
  {
1274
1306
  provide: NG_VALUE_ACCESSOR,
1275
1307
  useExisting: forwardRef(() => AfRadioComponent),
1276
- multi: true
1277
- }
1308
+ multi: true,
1309
+ },
1278
1310
  ], template: `
1279
1311
  <label class="ct-radio">
1280
1312
  <input
1313
+ #inputEl
1281
1314
  class="ct-radio__input"
1282
1315
  type="radio"
1283
- [name]="name()"
1316
+ [name]="resolvedName()"
1284
1317
  [value]="value()"
1285
1318
  [checked]="isChecked()"
1286
- [disabled]="disabled()"
1287
- (change)="onChangeEvent($event)"
1319
+ [disabled]="isDisabled()"
1320
+ [attr.tabindex]="resolvedTabindex()"
1321
+ (change)="onChangeEvent()"
1288
1322
  (blur)="onTouched()"
1289
- />
1323
+ (focus)="onFocus()"
1324
+ (keydown)="onKeydown($event)" />
1290
1325
  <span class="ct-radio__label">
1291
- <ng-content></ng-content>
1326
+ <ng-content />
1292
1327
  </span>
1293
1328
  </label>
1294
1329
  `, styles: [":host{display:block}\n"] }]
1295
- }], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }] } });
1330
+ }], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], inputRef: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }] } });
1331
+ // ── Radio Group ──────────────────────────────────────────────────────────────
1332
+ /**
1333
+ * Groups `af-radio` components with `role="radiogroup"`, ARIA labeling,
1334
+ * roving tabindex, and arrow-key navigation per WAI-ARIA Radio Group Pattern.
1335
+ *
1336
+ * Implements `ControlValueAccessor` so the group value can be bound via
1337
+ * `[(ngModel)]` or reactive forms.
1338
+ *
1339
+ * @example
1340
+ * <af-radio-group ariaLabel="Select plan" name="plan" [(ngModel)]="plan">
1341
+ * <af-radio value="standard">Standard</af-radio>
1342
+ * <af-radio value="premium">Premium</af-radio>
1343
+ * </af-radio-group>
1344
+ */
1345
+ class AfRadioGroupComponent {
1346
+ /** Shared `name` attribute for all child radios. */
1347
+ name = input.required(...(ngDevMode ? [{ debugName: "name" }] : []));
1348
+ /** Accessible label for the radio group. */
1349
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
1350
+ /** ID of an external element labeling this group. */
1351
+ ariaLabelledBy = input('', ...(ngDevMode ? [{ debugName: "ariaLabelledBy" }] : []));
1352
+ /** Disables all radios in the group. */
1353
+ disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
1354
+ radios = contentChildren(AfRadioComponent, ...(ngDevMode ? [{ debugName: "radios" }] : []));
1355
+ selectedValue = signal(undefined, ...(ngDevMode ? [{ debugName: "selectedValue" }] : []));
1356
+ focusedIndex = signal(0, ...(ngDevMode ? [{ debugName: "focusedIndex" }] : []));
1357
+ onChangeCallback = () => { };
1358
+ onTouchedCallback = () => { };
1359
+ syncEffect = effect(() => {
1360
+ const radios = this.radios();
1361
+ const value = this.selectedValue();
1362
+ const checkedIdx = radios.findIndex((r) => r.value() === value);
1363
+ this.focusedIndex.set(checkedIdx >= 0 ? checkedIdx : 0);
1364
+ }, ...(ngDevMode ? [{ debugName: "syncEffect" }] : []));
1365
+ /** Returns the tabindex a child radio should use for roving tabindex. */
1366
+ tabindexFor(radio) {
1367
+ const radios = this.enabledRadios();
1368
+ const idx = radios.indexOf(radio);
1369
+ if (idx === -1)
1370
+ return -1;
1371
+ return idx === this.focusedIndex() ? 0 : -1;
1372
+ }
1373
+ /** Selects a radio and propagates the value. */
1374
+ selectRadio(radio) {
1375
+ const value = radio.value();
1376
+ this.selectedValue.set(value);
1377
+ this.onChangeCallback(value);
1378
+ this.onTouchedCallback();
1379
+ }
1380
+ /** Called when a child radio receives focus. */
1381
+ onRadioFocus(radio) {
1382
+ const idx = this.enabledRadios().indexOf(radio);
1383
+ if (idx >= 0) {
1384
+ this.focusedIndex.set(idx);
1385
+ }
1386
+ }
1387
+ /** Handles keyboard navigation within the group. */
1388
+ onRadioKeydown(event, _current) {
1389
+ const enabled = this.enabledRadios();
1390
+ if (enabled.length === 0)
1391
+ return;
1392
+ let nextIndex = null;
1393
+ switch (event.key) {
1394
+ case 'ArrowDown':
1395
+ case 'ArrowRight':
1396
+ event.preventDefault();
1397
+ nextIndex = (this.focusedIndex() + 1) % enabled.length;
1398
+ break;
1399
+ case 'ArrowUp':
1400
+ case 'ArrowLeft':
1401
+ event.preventDefault();
1402
+ nextIndex = (this.focusedIndex() - 1 + enabled.length) % enabled.length;
1403
+ break;
1404
+ case 'Home':
1405
+ event.preventDefault();
1406
+ nextIndex = 0;
1407
+ break;
1408
+ case 'End':
1409
+ event.preventDefault();
1410
+ nextIndex = enabled.length - 1;
1411
+ break;
1412
+ case ' ':
1413
+ event.preventDefault();
1414
+ this.selectRadio(enabled[this.focusedIndex()]);
1415
+ return;
1416
+ }
1417
+ if (nextIndex !== null) {
1418
+ this.focusedIndex.set(nextIndex);
1419
+ const target = enabled[nextIndex];
1420
+ target.focus();
1421
+ this.selectRadio(target);
1422
+ }
1423
+ }
1424
+ writeValue(value) {
1425
+ this.selectedValue.set(value);
1426
+ }
1427
+ registerOnChange(fn) {
1428
+ this.onChangeCallback = fn;
1429
+ }
1430
+ registerOnTouched(fn) {
1431
+ this.onTouchedCallback = fn;
1432
+ }
1433
+ setDisabledState(isDisabled) {
1434
+ this.disabled.set(isDisabled);
1435
+ }
1436
+ enabledRadios() {
1437
+ return this.radios().filter((r) => !r.isDisabled());
1438
+ }
1439
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfRadioGroupComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1440
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.2", type: AfRadioGroupComponent, isStandalone: true, selector: "af-radio-group", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: true, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledBy: { classPropertyName: "ariaLabelledBy", publicName: "ariaLabelledBy", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange" }, providers: [
1441
+ {
1442
+ provide: NG_VALUE_ACCESSOR,
1443
+ useExisting: forwardRef(() => AfRadioGroupComponent),
1444
+ multi: true,
1445
+ },
1446
+ ], queries: [{ propertyName: "radios", predicate: AfRadioComponent, isSignal: true }], ngImport: i0, template: `
1447
+ <div
1448
+ role="radiogroup"
1449
+ [attr.aria-label]="ariaLabel() || null"
1450
+ [attr.aria-labelledby]="ariaLabelledBy() || null"
1451
+ [attr.aria-disabled]="disabled() || null">
1452
+ <ng-content />
1453
+ </div>
1454
+ `, isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1455
+ }
1456
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfRadioGroupComponent, decorators: [{
1457
+ type: Component,
1458
+ args: [{ selector: 'af-radio-group', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
1459
+ {
1460
+ provide: NG_VALUE_ACCESSOR,
1461
+ useExisting: forwardRef(() => AfRadioGroupComponent),
1462
+ multi: true,
1463
+ },
1464
+ ], template: `
1465
+ <div
1466
+ role="radiogroup"
1467
+ [attr.aria-label]="ariaLabel() || null"
1468
+ [attr.aria-labelledby]="ariaLabelledBy() || null"
1469
+ [attr.aria-disabled]="disabled() || null">
1470
+ <ng-content />
1471
+ </div>
1472
+ `, styles: [":host{display:block}\n"] }]
1473
+ }], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: true }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], ariaLabelledBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabelledBy", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], radios: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => AfRadioComponent), { isSignal: true }] }] } });
1296
1474
 
1297
1475
  /**
1298
1476
  * Switch/Toggle component with form control support
@@ -1303,7 +1481,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1303
1481
  * </af-switch>
1304
1482
  */
1305
1483
  class AfSwitchComponent {
1306
- /** Whether switch is disabled */
1484
+ /** Accessible label for icon-only or unlabeled switches. */
1485
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
1486
+ /** Whether switch is disabled. */
1307
1487
  disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
1308
1488
  /** Checked state - supports two-way binding via [(checked)] */
1309
1489
  checked = model(false, ...(ngDevMode ? [{ debugName: "checked" }] : []));
@@ -1328,7 +1508,7 @@ class AfSwitchComponent {
1328
1508
  this.disabled.set(isDisabled);
1329
1509
  }
1330
1510
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfSwitchComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1331
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.2", type: AfSwitchComponent, isStandalone: true, selector: "af-switch", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, checked: { classPropertyName: "checked", publicName: "checked", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange", checked: "checkedChange" }, providers: [
1511
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.2", type: AfSwitchComponent, isStandalone: true, selector: "af-switch", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, checked: { classPropertyName: "checked", publicName: "checked", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange", checked: "checkedChange" }, providers: [
1332
1512
  {
1333
1513
  provide: NG_VALUE_ACCESSOR,
1334
1514
  useExisting: forwardRef(() => AfSwitchComponent),
@@ -1342,9 +1522,9 @@ class AfSwitchComponent {
1342
1522
  role="switch"
1343
1523
  [checked]="checked()"
1344
1524
  [disabled]="disabled()"
1525
+ [attr.aria-label]="ariaLabel() || null"
1345
1526
  (change)="onChange($event)"
1346
- (blur)="onTouched()"
1347
- />
1527
+ (blur)="onTouched()" />
1348
1528
  <span class="ct-switch__label">
1349
1529
  <ng-content></ng-content>
1350
1530
  </span>
@@ -1367,57 +1547,58 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1367
1547
  role="switch"
1368
1548
  [checked]="checked()"
1369
1549
  [disabled]="disabled()"
1550
+ [attr.aria-label]="ariaLabel() || null"
1370
1551
  (change)="onChange($event)"
1371
- (blur)="onTouched()"
1372
- />
1552
+ (blur)="onTouched()" />
1373
1553
  <span class="ct-switch__label">
1374
1554
  <ng-content></ng-content>
1375
1555
  </span>
1376
1556
  </label>
1377
1557
  `, styles: [":host{display:block}\n"] }]
1378
- }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], checked: [{ type: i0.Input, args: [{ isSignal: true, alias: "checked", required: false }] }, { type: i0.Output, args: ["checkedChange"] }] } });
1558
+ }], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], checked: [{ type: i0.Input, args: [{ isSignal: true, alias: "checked", required: false }] }, { type: i0.Output, args: ["checkedChange"] }] } });
1379
1559
 
1380
1560
  /**
1381
- * Card component for containing content
1561
+ * Card component for containing content.
1562
+ *
1563
+ * When `interactive` is set the card becomes keyboard-accessible with
1564
+ * `role="button"`, roving `tabindex`, and Enter/Space activation.
1382
1565
  *
1383
1566
  * @example
1384
1567
  * <af-card elevation="md" padding="lg">
1385
- * <div header>
1386
- * <h3>Title</h3>
1387
- * </div>
1388
- * <div body>
1389
- * <p>Card content</p>
1390
- * </div>
1568
+ * <div header><h3>Title</h3></div>
1569
+ * <div body><p>Card content</p></div>
1570
+ * </af-card>
1571
+ *
1572
+ * <af-card interactive ariaLabel="Open project" (cardClick)="open()">
1573
+ * <p body>Click me</p>
1391
1574
  * </af-card>
1392
1575
  */
1393
1576
  class AfCardComponent {
1394
- /** Whether card is interactive (clickable/hoverable) */
1577
+ /** Makes the card interactive (clickable, keyboard-accessible). */
1395
1578
  interactive = input(false, ...(ngDevMode ? [{ debugName: "interactive" }] : []));
1396
- /** Shadow elevation level */
1579
+ /** Shadow elevation level. */
1397
1580
  elevation = input(null, ...(ngDevMode ? [{ debugName: "elevation" }] : []));
1398
- /** Content padding level */
1581
+ /** Content padding level. */
1399
1582
  padding = input(null, ...(ngDevMode ? [{ debugName: "padding" }] : []));
1400
- /** Click event emitter */
1583
+ /** Accessible label for interactive cards. */
1584
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
1585
+ /** Emitted when an interactive card is activated (click, Enter, or Space). */
1401
1586
  cardClick = output();
1402
- hasHeader = signal(false, ...(ngDevMode ? [{ debugName: "hasHeader" }] : []));
1403
- hasFooter = signal(false, ...(ngDevMode ? [{ debugName: "hasFooter" }] : []));
1404
- set headerContent(value) {
1405
- this.hasHeader.set(!!value);
1406
- }
1407
- set footerContent(value) {
1408
- this.hasFooter.set(!!value);
1409
- }
1587
+ headerRef = contentChild('[header]', { ...(ngDevMode ? { debugName: "headerRef" } : {}), read: ElementRef });
1588
+ footerRef = contentChild('[footer]', { ...(ngDevMode ? { debugName: "footerRef" } : {}), read: ElementRef });
1589
+ hasHeader = computed(() => !!this.headerRef(), ...(ngDevMode ? [{ debugName: "hasHeader" }] : []));
1590
+ hasFooter = computed(() => !!this.footerRef(), ...(ngDevMode ? [{ debugName: "hasFooter" }] : []));
1410
1591
  static ELEVATION_MAP = {
1411
1592
  none: 'none',
1412
1593
  sm: '0 1px 3px rgba(0, 0, 0, 0.08)',
1413
1594
  md: '0 4px 12px rgba(0, 0, 0, 0.08)',
1414
- lg: '0 8px 24px rgba(0, 0, 0, 0.12)'
1595
+ lg: '0 8px 24px rgba(0, 0, 0, 0.12)',
1415
1596
  };
1416
1597
  static PADDING_MAP = {
1417
1598
  none: '0',
1418
1599
  sm: 'var(--space-3, 0.75rem)',
1419
1600
  md: 'var(--space-5, 1.25rem)',
1420
- lg: 'var(--space-7, 2rem)'
1601
+ lg: 'var(--space-7, 2rem)',
1421
1602
  };
1422
1603
  cardClasses = computed(() => {
1423
1604
  const classes = ['ct-card'];
@@ -1441,24 +1622,36 @@ class AfCardComponent {
1441
1622
  this.cardClick.emit();
1442
1623
  }
1443
1624
  }
1625
+ onCardKeydown(event) {
1626
+ if (!this.interactive())
1627
+ return;
1628
+ if (event.key === 'Enter' || event.key === ' ') {
1629
+ event.preventDefault();
1630
+ this.cardClick.emit();
1631
+ }
1632
+ }
1444
1633
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfCardComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1445
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfCardComponent, isStandalone: true, selector: "af-card", inputs: { interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null }, elevation: { classPropertyName: "elevation", publicName: "elevation", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { cardClick: "cardClick" }, queries: [{ propertyName: "headerContent", first: true, predicate: ["[header]"], descendants: true, read: ElementRef }, { propertyName: "footerContent", first: true, predicate: ["[footer]"], descendants: true, read: ElementRef }], ngImport: i0, template: `
1634
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfCardComponent, isStandalone: true, selector: "af-card", inputs: { interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null }, elevation: { classPropertyName: "elevation", publicName: "elevation", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { cardClick: "cardClick" }, queries: [{ propertyName: "headerRef", first: true, predicate: ["[header]"], descendants: true, read: ElementRef, isSignal: true }, { propertyName: "footerRef", first: true, predicate: ["[footer]"], descendants: true, read: ElementRef, isSignal: true }], ngImport: i0, template: `
1446
1635
  <section
1447
1636
  [class]="cardClasses()"
1448
1637
  [style]="cardStyles()"
1449
- (click)="onCardClick()">
1638
+ [attr.role]="interactive() ? 'button' : null"
1639
+ [attr.tabindex]="interactive() ? 0 : null"
1640
+ [attr.aria-label]="ariaLabel() || null"
1641
+ (click)="onCardClick()"
1642
+ (keydown)="onCardKeydown($event)">
1450
1643
  @if (hasHeader()) {
1451
1644
  <div class="ct-card__header">
1452
- <ng-content select="[header]"></ng-content>
1645
+ <ng-content select="[header]" />
1453
1646
  </div>
1454
1647
  }
1455
1648
  <div class="ct-card__body">
1456
- <ng-content select="[body]"></ng-content>
1457
- <ng-content></ng-content>
1649
+ <ng-content select="[body]" />
1650
+ <ng-content />
1458
1651
  </div>
1459
1652
  @if (hasFooter()) {
1460
1653
  <div class="ct-card__footer">
1461
- <ng-content select="[footer]"></ng-content>
1654
+ <ng-content select="[footer]" />
1462
1655
  </div>
1463
1656
  }
1464
1657
  </section>
@@ -1470,30 +1663,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1470
1663
  <section
1471
1664
  [class]="cardClasses()"
1472
1665
  [style]="cardStyles()"
1473
- (click)="onCardClick()">
1666
+ [attr.role]="interactive() ? 'button' : null"
1667
+ [attr.tabindex]="interactive() ? 0 : null"
1668
+ [attr.aria-label]="ariaLabel() || null"
1669
+ (click)="onCardClick()"
1670
+ (keydown)="onCardKeydown($event)">
1474
1671
  @if (hasHeader()) {
1475
1672
  <div class="ct-card__header">
1476
- <ng-content select="[header]"></ng-content>
1673
+ <ng-content select="[header]" />
1477
1674
  </div>
1478
1675
  }
1479
1676
  <div class="ct-card__body">
1480
- <ng-content select="[body]"></ng-content>
1481
- <ng-content></ng-content>
1677
+ <ng-content select="[body]" />
1678
+ <ng-content />
1482
1679
  </div>
1483
1680
  @if (hasFooter()) {
1484
1681
  <div class="ct-card__footer">
1485
- <ng-content select="[footer]"></ng-content>
1682
+ <ng-content select="[footer]" />
1486
1683
  </div>
1487
1684
  }
1488
1685
  </section>
1489
1686
  `, styles: [":host{display:block}\n"] }]
1490
- }], propDecorators: { interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }], elevation: [{ type: i0.Input, args: [{ isSignal: true, alias: "elevation", required: false }] }], padding: [{ type: i0.Input, args: [{ isSignal: true, alias: "padding", required: false }] }], cardClick: [{ type: i0.Output, args: ["cardClick"] }], headerContent: [{
1491
- type: ContentChild,
1492
- args: ['[header]', { read: ElementRef }]
1493
- }], footerContent: [{
1494
- type: ContentChild,
1495
- args: ['[footer]', { read: ElementRef }]
1496
- }] } });
1687
+ }], propDecorators: { interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }], elevation: [{ type: i0.Input, args: [{ isSignal: true, alias: "elevation", required: false }] }], padding: [{ type: i0.Input, args: [{ isSignal: true, alias: "padding", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], cardClick: [{ type: i0.Output, args: ["cardClick"] }], headerRef: [{ type: i0.ContentChild, args: ['[header]', { ...{ read: ElementRef }, isSignal: true }] }], footerRef: [{ type: i0.ContentChild, args: ['[footer]', { ...{ read: ElementRef }, isSignal: true }] }] } });
1497
1688
 
1498
1689
  /**
1499
1690
  * Directive for defining custom cell templates in AfDataTableComponent.
@@ -1721,6 +1912,85 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1721
1912
  args: [{ selector: 'af-data-table', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgTemplateOutlet], template: "<div class=\"ct-data-table ct-data-table--simple\">\n <div class=\"ct-data-table__table\">\n <table [class]=\"tableClasses()\">\n <thead>\n <tr>\n @if (isSelectable()) {\n <th scope=\"col\" class=\"ct-table__cell--checkbox\">\n <label class=\"ct-check\">\n <input\n class=\"ct-check__input\"\n type=\"checkbox\"\n [checked]=\"allSelected\"\n [indeterminate]=\"someSelected\"\n (change)=\"toggleAll($any($event.target).checked)\"\n aria-label=\"Select all rows\" />\n </label>\n </th>\n }\n @for (col of columns(); track col.key) {\n <th scope=\"col\" [attr.aria-sort]=\"getAriaSort(col)\">\n @if (col.sortable) {\n <button class=\"ct-table__sort\" type=\"button\" (click)=\"toggleSort(col)\">\n {{ col.header }}\n <span class=\"ct-table__sort-indicator\" aria-hidden=\"true\"></span>\n </button>\n } @else {\n {{ col.header }}\n }\n </th>\n }\n </tr>\n </thead>\n <tbody>\n @for (row of sortedData(); track row) {\n <tr (click)=\"onRowClick(row)\">\n @if (isSelectable()) {\n <td class=\"ct-table__cell--checkbox\">\n <label class=\"ct-check\">\n <input\n class=\"ct-check__input\"\n type=\"checkbox\"\n [checked]=\"isSelected(row)\"\n (change)=\"toggleSelection(row, $event)\"\n aria-label=\"Select row\" />\n </label>\n </td>\n }\n @for (col of columns(); track col.key) {\n <td [class]=\"col.cellClass || ''\">\n @if (getCellTemplate(col.key); as tmpl) {\n <ng-container *ngTemplateOutlet=\"tmpl; context: { $implicit: row, column: col }\"></ng-container>\n } @else {\n {{ row[col.key] }}\n }\n </td>\n }\n </tr>\n }\n </tbody>\n </table>\n </div>\n</div>\n", styles: [":host{display:block}.ct-data-table__table tr{cursor:pointer}.ct-data-table__table tr:hover{background-color:var(--color-neutral-50)}\n"] }]
1722
1913
  }], propDecorators: { data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: false }] }], sort: [{ type: i0.Input, args: [{ isSignal: true, alias: "sort", required: false }] }], rowId: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowId", required: false }] }], rowClick: [{ type: i0.Output, args: ["rowClick"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], sortChange: [{ type: i0.Output, args: ["sortChange"] }], cellDefs: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => AfCellDefDirective), { isSignal: true }] }] } });
1723
1914
 
1915
+ const FOCUSABLE_SELECTORS = [
1916
+ 'a[href]',
1917
+ 'area[href]',
1918
+ 'button:not([disabled])',
1919
+ 'input:not([disabled])',
1920
+ 'select:not([disabled])',
1921
+ 'textarea:not([disabled])',
1922
+ '[tabindex]:not([tabindex="-1"])',
1923
+ ].join(',');
1924
+ /**
1925
+ * Manages focus trapping within a container element.
1926
+ *
1927
+ * Handles Tab/Shift+Tab cycling, save/restore of the previously focused
1928
+ * element, and initial focus placement. Create one instance per overlay
1929
+ * (modal, drawer, popover, etc.).
1930
+ */
1931
+ class FocusTrap {
1932
+ previousActiveElement = null;
1933
+ /** Saves the currently focused element for later restoration. */
1934
+ saveFocus() {
1935
+ this.previousActiveElement = document.activeElement;
1936
+ }
1937
+ /** Restores focus to the element saved via `saveFocus()`. */
1938
+ restoreFocus() {
1939
+ if (this.previousActiveElement) {
1940
+ this.previousActiveElement.focus();
1941
+ this.previousActiveElement = null;
1942
+ }
1943
+ }
1944
+ /** Overrides the saved focus target (e.g. to return focus to a specific trigger). */
1945
+ setReturnFocus(element) {
1946
+ this.previousActiveElement = element;
1947
+ }
1948
+ /**
1949
+ * Focuses the first focusable element inside the container,
1950
+ * or the fallback element if no focusable children exist.
1951
+ */
1952
+ focusFirst(container, fallback) {
1953
+ const elements = queryFocusableElements(container);
1954
+ const first = elements[0];
1955
+ if (first) {
1956
+ first.focus();
1957
+ }
1958
+ else {
1959
+ fallback?.focus();
1960
+ }
1961
+ }
1962
+ /**
1963
+ * Handles a Tab keydown event to trap focus within the container.
1964
+ * Call this from a `(keydown)` handler when the key is `Tab`.
1965
+ */
1966
+ handleTab(event, container, fallback) {
1967
+ const elements = queryFocusableElements(container);
1968
+ if (elements.length === 0) {
1969
+ event.preventDefault();
1970
+ fallback?.focus();
1971
+ return;
1972
+ }
1973
+ const first = elements[0];
1974
+ const last = elements[elements.length - 1];
1975
+ const active = document.activeElement;
1976
+ if (event.shiftKey && active === first) {
1977
+ event.preventDefault();
1978
+ last.focus();
1979
+ }
1980
+ else if (!event.shiftKey && active === last) {
1981
+ event.preventDefault();
1982
+ first.focus();
1983
+ }
1984
+ }
1985
+ }
1986
+ /** Queries all visible, enabled, focusable elements within a container. */
1987
+ function queryFocusableElements(container) {
1988
+ if (!container)
1989
+ return [];
1990
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)).filter((el) => !el.hasAttribute('disabled') &&
1991
+ el.getAttribute('aria-hidden') !== 'true');
1992
+ }
1993
+
1724
1994
  /**
1725
1995
  * Modal/Dialog component with accessibility features
1726
1996
  *
@@ -1740,6 +2010,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1740
2010
  */
1741
2011
  class AfModalComponent {
1742
2012
  static nextId = 0;
2013
+ focusTrap = new FocusTrap();
1743
2014
  /** Whether modal is open */
1744
2015
  open = input(false, ...(ngDevMode ? [{ debugName: "open" }] : []));
1745
2016
  /** Modal title */
@@ -1752,12 +2023,8 @@ class AfModalComponent {
1752
2023
  closed = output();
1753
2024
  /** Unique title ID for aria-labelledby */
1754
2025
  titleId = `af-modal-title-${AfModalComponent.nextId++}`;
1755
- hasFooter = signal(false, ...(ngDevMode ? [{ debugName: "hasFooter" }] : []));
1756
- set footerContent(value) {
1757
- this.hasFooter.set(!!value);
1758
- }
1759
- previousActiveElement = null;
1760
- focusableElements = [];
2026
+ footerRef = contentChild('[footer]', { ...(ngDevMode ? { debugName: "footerRef" } : {}), read: ElementRef });
2027
+ hasFooter = computed(() => !!this.footerRef(), ...(ngDevMode ? [{ debugName: "hasFooter" }] : []));
1761
2028
  viewInitialized = signal(false, ...(ngDevMode ? [{ debugName: "viewInitialized" }] : []));
1762
2029
  dialogRef = viewChild('dialog', ...(ngDevMode ? [{ debugName: "dialogRef" }] : []));
1763
2030
  openEffect = effect(() => {
@@ -1767,14 +2034,14 @@ class AfModalComponent {
1767
2034
  this.onOpen();
1768
2035
  }
1769
2036
  else if (!isOpen) {
1770
- this.restoreFocus();
2037
+ this.focusTrap.restoreFocus();
1771
2038
  }
1772
2039
  }, ...(ngDevMode ? [{ debugName: "openEffect" }] : []));
1773
2040
  ngAfterViewInit() {
1774
2041
  this.viewInitialized.set(true);
1775
2042
  }
1776
2043
  ngOnDestroy() {
1777
- this.restoreFocus();
2044
+ this.focusTrap.restoreFocus();
1778
2045
  }
1779
2046
  onEscapeKey() {
1780
2047
  if (this.open()) {
@@ -1784,23 +2051,7 @@ class AfModalComponent {
1784
2051
  onKeydown(event) {
1785
2052
  if (!this.open() || event.key !== 'Tab')
1786
2053
  return;
1787
- this.refreshFocusableElements();
1788
- if (this.focusableElements.length === 0) {
1789
- event.preventDefault();
1790
- this.dialogRef()?.nativeElement.focus();
1791
- return;
1792
- }
1793
- const first = this.focusableElements[0];
1794
- const last = this.focusableElements[this.focusableElements.length - 1];
1795
- const active = document.activeElement;
1796
- if (event.shiftKey && active === first) {
1797
- event.preventDefault();
1798
- last.focus();
1799
- }
1800
- else if (!event.shiftKey && active === last) {
1801
- event.preventDefault();
1802
- first.focus();
1803
- }
2054
+ this.focusTrap.handleTab(event, this.dialogRef()?.nativeElement, this.dialogRef()?.nativeElement);
1804
2055
  }
1805
2056
  onBackdropClick(event) {
1806
2057
  if (this.closeOnBackdropClick() && event.target === event.currentTarget) {
@@ -1811,46 +2062,15 @@ class AfModalComponent {
1811
2062
  this.closed.emit();
1812
2063
  }
1813
2064
  onOpen() {
1814
- this.previousActiveElement = document.activeElement;
1815
- this.refreshFocusableElements();
2065
+ this.focusTrap.saveFocus();
1816
2066
  queueMicrotask(() => {
1817
2067
  if (!this.open())
1818
2068
  return;
1819
- const first = this.focusableElements[0];
1820
- if (first) {
1821
- first.focus();
1822
- }
1823
- else {
1824
- this.dialogRef()?.nativeElement.focus();
1825
- }
2069
+ this.focusTrap.focusFirst(this.dialogRef()?.nativeElement, this.dialogRef()?.nativeElement);
1826
2070
  });
1827
2071
  }
1828
- restoreFocus() {
1829
- if (this.previousActiveElement) {
1830
- this.previousActiveElement.focus();
1831
- this.previousActiveElement = null;
1832
- }
1833
- }
1834
- refreshFocusableElements() {
1835
- const dialog = this.dialogRef()?.nativeElement;
1836
- if (!dialog) {
1837
- this.focusableElements = [];
1838
- return;
1839
- }
1840
- const selectors = [
1841
- 'a[href]',
1842
- 'area[href]',
1843
- 'button:not([disabled])',
1844
- 'input:not([disabled])',
1845
- 'select:not([disabled])',
1846
- 'textarea:not([disabled])',
1847
- '[tabindex]:not([tabindex="-1"])'
1848
- ];
1849
- this.focusableElements = Array.from(dialog.querySelectorAll(selectors.join(',')))
1850
- .filter(el => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true');
1851
- }
1852
2072
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1853
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfModalComponent, isStandalone: true, selector: "af-modal", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, showCloseButton: { classPropertyName: "showCloseButton", publicName: "showCloseButton", isSignal: true, isRequired: false, transformFunction: null }, closeOnBackdropClick: { classPropertyName: "closeOnBackdropClick", publicName: "closeOnBackdropClick", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { closed: "closed" }, host: { listeners: { "document:keydown.escape": "onEscapeKey()", "document:keydown": "onKeydown($event)" } }, queries: [{ propertyName: "footerContent", first: true, predicate: ["[footer]"], descendants: true, read: ElementRef }], viewQueries: [{ propertyName: "dialogRef", first: true, predicate: ["dialog"], descendants: true, isSignal: true }], ngImport: i0, template: `
2073
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfModalComponent, isStandalone: true, selector: "af-modal", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, showCloseButton: { classPropertyName: "showCloseButton", publicName: "showCloseButton", isSignal: true, isRequired: false, transformFunction: null }, closeOnBackdropClick: { classPropertyName: "closeOnBackdropClick", publicName: "closeOnBackdropClick", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { closed: "closed" }, host: { listeners: { "document:keydown.escape": "onEscapeKey()", "document:keydown": "onKeydown($event)" } }, queries: [{ propertyName: "footerRef", first: true, predicate: ["[footer]"], descendants: true, read: ElementRef, isSignal: true }], viewQueries: [{ propertyName: "dialogRef", first: true, predicate: ["dialog"], descendants: true, isSignal: true }], ngImport: i0, template: `
1854
2074
  @if (open()) {
1855
2075
  <div
1856
2076
  class="ct-modal"
@@ -1932,10 +2152,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1932
2152
  </div>
1933
2153
  }
1934
2154
  `, styles: [":host{display:contents}\n"] }]
1935
- }], propDecorators: { open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], showCloseButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCloseButton", required: false }] }], closeOnBackdropClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeOnBackdropClick", required: false }] }], closed: [{ type: i0.Output, args: ["closed"] }], footerContent: [{
1936
- type: ContentChild,
1937
- args: ['[footer]', { read: ElementRef }]
1938
- }], dialogRef: [{ type: i0.ViewChild, args: ['dialog', { isSignal: true }] }] } });
2155
+ }], propDecorators: { open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], showCloseButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCloseButton", required: false }] }], closeOnBackdropClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeOnBackdropClick", required: false }] }], closed: [{ type: i0.Output, args: ["closed"] }], footerRef: [{ type: i0.ContentChild, args: ['[footer]', { ...{ read: ElementRef }, isSignal: true }] }], dialogRef: [{ type: i0.ViewChild, args: ['dialog', { isSignal: true }] }] } });
1939
2156
 
1940
2157
  /**
1941
2158
  * Toast notification service
@@ -2297,7 +2514,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
2297
2514
  }], propDecorators: { activeTab: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeTab", required: false }] }, { type: i0.Output, args: ["activeTabChange"] }], panels: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => AfTabPanelComponent), { isSignal: true }] }], tabButtons: [{ type: i0.ViewChildren, args: ['tabButton', { isSignal: true }] }] } });
2298
2515
 
2299
2516
  /**
2300
- * Dropdown menu component
2517
+ * Dropdown menu component implementing the WAI-ARIA Menu Pattern.
2518
+ *
2519
+ * Provides full keyboard navigation (Arrow keys, Home/End, type-ahead),
2520
+ * proper ARIA roles (`menu` / `menuitem`), and roving tabindex focus management.
2301
2521
  *
2302
2522
  * @example
2303
2523
  * <af-dropdown
@@ -2308,19 +2528,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
2308
2528
  */
2309
2529
  class AfDropdownComponent {
2310
2530
  static nextId = 0;
2311
- /** Dropdown button label */
2531
+ /** Dropdown button label. */
2312
2532
  label = input('Actions', ...(ngDevMode ? [{ debugName: "label" }] : []));
2313
- /** Menu items */
2533
+ /** Menu items. */
2314
2534
  items = input([], ...(ngDevMode ? [{ debugName: "items" }] : []));
2315
- /** Item selected event */
2535
+ /** Emits the selected item's value. */
2316
2536
  itemSelected = output();
2317
2537
  triggerRef = viewChild('trigger', ...(ngDevMode ? [{ debugName: "triggerRef" }] : []));
2538
+ menuRef = viewChild('menu', ...(ngDevMode ? [{ debugName: "menuRef" }] : []));
2318
2539
  itemButtons = viewChildren('itemButton', ...(ngDevMode ? [{ debugName: "itemButtons" }] : []));
2319
2540
  isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : []));
2320
- menuId = `af-dropdown-menu-${AfDropdownComponent.nextId++}`;
2541
+ focusedItemIndex = signal(0, ...(ngDevMode ? [{ debugName: "focusedItemIndex" }] : []));
2542
+ instanceId = AfDropdownComponent.nextId++;
2543
+ menuId = `af-dropdown-menu-${this.instanceId}`;
2544
+ triggerId = `af-dropdown-trigger-${this.instanceId}`;
2545
+ typeAheadBuffer = '';
2546
+ typeAheadTimer = null;
2321
2547
  toggle() {
2322
2548
  if (this.isOpen()) {
2323
- this.close();
2549
+ this.close(true);
2324
2550
  }
2325
2551
  else {
2326
2552
  this.open();
@@ -2332,46 +2558,180 @@ class AfDropdownComponent {
2332
2558
  this.close(true);
2333
2559
  }
2334
2560
  }
2335
- open() {
2336
- this.isOpen.set(true);
2337
- queueMicrotask(() => this.focusFirstItem());
2338
- }
2339
- close(returnFocus = false) {
2340
- this.isOpen.set(false);
2341
- if (returnFocus) {
2342
- this.triggerRef()?.nativeElement.focus();
2561
+ /** Handles keyboard events on the trigger button. */
2562
+ onTriggerKeydown(event) {
2563
+ switch (event.key) {
2564
+ case 'ArrowDown':
2565
+ case 'Enter':
2566
+ case ' ':
2567
+ event.preventDefault();
2568
+ if (!this.isOpen()) {
2569
+ this.open();
2570
+ }
2571
+ break;
2572
+ case 'ArrowUp':
2573
+ event.preventDefault();
2574
+ if (!this.isOpen()) {
2575
+ this.open(true);
2576
+ }
2577
+ break;
2343
2578
  }
2344
2579
  }
2345
- focusFirstItem() {
2346
- const first = this.itemButtons().find(ref => !ref.nativeElement.disabled);
2347
- first?.nativeElement.focus();
2580
+ /** Handles keyboard events within the open menu. */
2581
+ onMenuKeydown(event) {
2582
+ const actionableItems = this.getActionableItems();
2583
+ if (actionableItems.length === 0)
2584
+ return;
2585
+ switch (event.key) {
2586
+ case 'ArrowDown': {
2587
+ event.preventDefault();
2588
+ const next = this.nextEnabledIndex(this.focusedItemIndex(), 1);
2589
+ this.focusItem(next);
2590
+ break;
2591
+ }
2592
+ case 'ArrowUp': {
2593
+ event.preventDefault();
2594
+ const prev = this.nextEnabledIndex(this.focusedItemIndex(), -1);
2595
+ this.focusItem(prev);
2596
+ break;
2597
+ }
2598
+ case 'Home': {
2599
+ event.preventDefault();
2600
+ const first = this.nextEnabledIndex(-1, 1);
2601
+ this.focusItem(first);
2602
+ break;
2603
+ }
2604
+ case 'End': {
2605
+ event.preventDefault();
2606
+ const last = this.nextEnabledIndex(actionableItems.length, -1);
2607
+ this.focusItem(last);
2608
+ break;
2609
+ }
2610
+ case 'Escape':
2611
+ event.preventDefault();
2612
+ this.close(true);
2613
+ break;
2614
+ case 'Tab':
2615
+ this.close(false);
2616
+ break;
2617
+ case 'Enter':
2618
+ case ' ': {
2619
+ event.preventDefault();
2620
+ const item = actionableItems[this.focusedItemIndex()];
2621
+ if (item && !item.disabled) {
2622
+ this.selectItem(item);
2623
+ }
2624
+ break;
2625
+ }
2626
+ default:
2627
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
2628
+ this.handleTypeAhead(event.key);
2629
+ }
2630
+ }
2348
2631
  }
2349
2632
  onDocumentClick(event) {
2350
2633
  const target = event.target;
2351
2634
  if (!target.closest('.ct-dropdown')) {
2352
- this.close();
2635
+ this.close(false);
2353
2636
  }
2354
2637
  }
2355
- onEscape() {
2356
- if (this.isOpen()) {
2357
- this.close(true);
2638
+ /**
2639
+ * Returns the index of a non-separator item within the list of
2640
+ * actionable (non-separator) items.
2641
+ */
2642
+ getActionableIndex(item) {
2643
+ return this.getActionableItems().indexOf(item);
2644
+ }
2645
+ open(focusLast = false) {
2646
+ this.isOpen.set(true);
2647
+ const actionableItems = this.getActionableItems();
2648
+ const startIndex = focusLast
2649
+ ? this.nextEnabledIndex(actionableItems.length, -1)
2650
+ : this.nextEnabledIndex(-1, 1);
2651
+ this.focusedItemIndex.set(startIndex);
2652
+ queueMicrotask(() => this.focusCurrent());
2653
+ }
2654
+ close(returnFocus) {
2655
+ if (!this.isOpen())
2656
+ return;
2657
+ this.isOpen.set(false);
2658
+ this.typeAheadBuffer = '';
2659
+ if (returnFocus) {
2660
+ this.triggerRef()?.nativeElement.focus();
2661
+ }
2662
+ }
2663
+ focusItem(index) {
2664
+ this.focusedItemIndex.set(index);
2665
+ this.focusCurrent();
2666
+ }
2667
+ focusCurrent() {
2668
+ const buttons = this.itemButtons();
2669
+ const idx = this.focusedItemIndex();
2670
+ buttons[idx]?.nativeElement.focus();
2671
+ }
2672
+ nextEnabledIndex(from, direction) {
2673
+ const actionableItems = this.getActionableItems();
2674
+ const len = actionableItems.length;
2675
+ if (len === 0)
2676
+ return 0;
2677
+ let idx = from + direction;
2678
+ for (let i = 0; i < len; i++) {
2679
+ if (idx < 0)
2680
+ idx = len - 1;
2681
+ if (idx >= len)
2682
+ idx = 0;
2683
+ if (!actionableItems[idx].disabled)
2684
+ return idx;
2685
+ idx += direction;
2686
+ }
2687
+ return from;
2688
+ }
2689
+ getActionableItems() {
2690
+ return this.items().filter((item) => !item.separator);
2691
+ }
2692
+ handleTypeAhead(char) {
2693
+ if (this.typeAheadTimer) {
2694
+ clearTimeout(this.typeAheadTimer);
2695
+ }
2696
+ this.typeAheadBuffer += char.toLowerCase();
2697
+ this.typeAheadTimer = setTimeout(() => {
2698
+ this.typeAheadBuffer = '';
2699
+ this.typeAheadTimer = null;
2700
+ }, 500);
2701
+ const actionableItems = this.getActionableItems();
2702
+ const startIndex = this.focusedItemIndex() + 1;
2703
+ for (let i = 0; i < actionableItems.length; i++) {
2704
+ const idx = (startIndex + i) % actionableItems.length;
2705
+ const item = actionableItems[idx];
2706
+ if (!item.disabled && item.label.toLowerCase().startsWith(this.typeAheadBuffer)) {
2707
+ this.focusItem(idx);
2708
+ return;
2709
+ }
2358
2710
  }
2359
2711
  }
2360
2712
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDropdownComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2361
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfDropdownComponent, isStandalone: true, selector: "af-dropdown", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemSelected: "itemSelected" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscape()" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true, isSignal: true }, { propertyName: "itemButtons", predicate: ["itemButton"], descendants: true, isSignal: true }], ngImport: i0, template: `
2713
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfDropdownComponent, isStandalone: true, selector: "af-dropdown", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemSelected: "itemSelected" }, host: { listeners: { "document:click": "onDocumentClick($event)" } }, viewQueries: [{ propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true, isSignal: true }, { propertyName: "menuRef", first: true, predicate: ["menu"], descendants: true, isSignal: true }, { propertyName: "itemButtons", predicate: ["itemButton"], descendants: true, isSignal: true }], ngImport: i0, template: `
2362
2714
  <div class="ct-dropdown" [attr.data-state]="isOpen() ? 'open' : 'closed'">
2363
2715
  <button
2364
2716
  #trigger
2365
2717
  class="ct-button ct-button--secondary ct-dropdown__trigger"
2366
2718
  [attr.aria-expanded]="isOpen()"
2367
2719
  [attr.aria-controls]="menuId"
2368
- [attr.aria-haspopup]="true"
2720
+ aria-haspopup="menu"
2369
2721
  type="button"
2370
- (click)="toggle()">
2722
+ (click)="toggle()"
2723
+ (keydown)="onTriggerKeydown($event)">
2371
2724
  {{ label() }}
2372
2725
  </button>
2373
2726
  @if (isOpen()) {
2374
- <div class="ct-dropdown__menu" [id]="menuId">
2727
+ <div
2728
+ #menu
2729
+ class="ct-dropdown__menu"
2730
+ [id]="menuId"
2731
+ role="menu"
2732
+ aria-orientation="vertical"
2733
+ [attr.aria-labelledby]="triggerId"
2734
+ (keydown)="onMenuKeydown($event)">
2375
2735
  @for (item of items(); track $index) {
2376
2736
  @if (item.separator) {
2377
2737
  <div class="ct-dropdown__separator" role="separator"></div>
@@ -2379,8 +2739,9 @@ class AfDropdownComponent {
2379
2739
  <button
2380
2740
  #itemButton
2381
2741
  class="ct-dropdown__item"
2382
- [disabled]="item.disabled"
2383
- [attr.aria-disabled]="item.disabled ? true : null"
2742
+ role="menuitem"
2743
+ [attr.tabindex]="focusedItemIndex() === getActionableIndex(item) ? 0 : -1"
2744
+ [attr.aria-disabled]="item.disabled ? 'true' : null"
2384
2745
  type="button"
2385
2746
  (click)="selectItem(item)">
2386
2747
  {{ item.label }}
@@ -2396,7 +2757,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
2396
2757
  type: Component,
2397
2758
  args: [{ selector: 'af-dropdown', changeDetection: ChangeDetectionStrategy.OnPush, host: {
2398
2759
  '(document:click)': 'onDocumentClick($event)',
2399
- '(document:keydown.escape)': 'onEscape()',
2400
2760
  }, template: `
2401
2761
  <div class="ct-dropdown" [attr.data-state]="isOpen() ? 'open' : 'closed'">
2402
2762
  <button
@@ -2404,13 +2764,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
2404
2764
  class="ct-button ct-button--secondary ct-dropdown__trigger"
2405
2765
  [attr.aria-expanded]="isOpen()"
2406
2766
  [attr.aria-controls]="menuId"
2407
- [attr.aria-haspopup]="true"
2767
+ aria-haspopup="menu"
2408
2768
  type="button"
2409
- (click)="toggle()">
2769
+ (click)="toggle()"
2770
+ (keydown)="onTriggerKeydown($event)">
2410
2771
  {{ label() }}
2411
2772
  </button>
2412
2773
  @if (isOpen()) {
2413
- <div class="ct-dropdown__menu" [id]="menuId">
2774
+ <div
2775
+ #menu
2776
+ class="ct-dropdown__menu"
2777
+ [id]="menuId"
2778
+ role="menu"
2779
+ aria-orientation="vertical"
2780
+ [attr.aria-labelledby]="triggerId"
2781
+ (keydown)="onMenuKeydown($event)">
2414
2782
  @for (item of items(); track $index) {
2415
2783
  @if (item.separator) {
2416
2784
  <div class="ct-dropdown__separator" role="separator"></div>
@@ -2418,8 +2786,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
2418
2786
  <button
2419
2787
  #itemButton
2420
2788
  class="ct-dropdown__item"
2421
- [disabled]="item.disabled"
2422
- [attr.aria-disabled]="item.disabled ? true : null"
2789
+ role="menuitem"
2790
+ [attr.tabindex]="focusedItemIndex() === getActionableIndex(item) ? 0 : -1"
2791
+ [attr.aria-disabled]="item.disabled ? 'true' : null"
2423
2792
  type="button"
2424
2793
  (click)="selectItem(item)">
2425
2794
  {{ item.label }}
@@ -2430,7 +2799,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
2430
2799
  }
2431
2800
  </div>
2432
2801
  `, styles: [":host{display:inline-block}\n"] }]
2433
- }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], itemSelected: [{ type: i0.Output, args: ["itemSelected"] }], triggerRef: [{ type: i0.ViewChild, args: ['trigger', { isSignal: true }] }], itemButtons: [{ type: i0.ViewChildren, args: ['itemButton', { isSignal: true }] }] } });
2802
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], itemSelected: [{ type: i0.Output, args: ["itemSelected"] }], triggerRef: [{ type: i0.ViewChild, args: ['trigger', { isSignal: true }] }], menuRef: [{ type: i0.ViewChild, args: ['menu', { isSignal: true }] }], itemButtons: [{ type: i0.ViewChildren, args: ['itemButton', { isSignal: true }] }] } });
2434
2803
 
2435
2804
  /**
2436
2805
  * Pagination component
@@ -3490,7 +3859,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
3490
3859
  * <af-badge variant="danger">Blocked</af-badge>
3491
3860
  */
3492
3861
  class AfBadgeComponent {
3493
- /** Color variant */
3862
+ /** Accessible label, useful when the badge has no visible text. */
3863
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
3864
+ /** Color variant. */
3494
3865
  variant = input('default', ...(ngDevMode ? [{ debugName: "variant" }] : []));
3495
3866
  /** Icon character to display */
3496
3867
  icon = input('', ...(ngDevMode ? [{ debugName: "icon" }] : []));
@@ -3507,32 +3878,32 @@ class AfBadgeComponent {
3507
3878
  return classes.join(' ');
3508
3879
  }, ...(ngDevMode ? [{ debugName: "badgeClasses" }] : []));
3509
3880
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfBadgeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3510
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfBadgeComponent, isStandalone: true, selector: "af-badge", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, dot: { classPropertyName: "dot", publicName: "dot", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
3511
- <span [class]="badgeClasses()">
3881
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfBadgeComponent, isStandalone: true, selector: "af-badge", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, dot: { classPropertyName: "dot", publicName: "dot", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
3882
+ <span [class]="badgeClasses()" [attr.aria-label]="ariaLabel() || null">
3512
3883
  @if (icon()) {
3513
3884
  <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
3514
3885
  }
3515
3886
  @if (dot()) {
3516
3887
  <span class="ct-badge__dot" aria-hidden="true"></span>
3517
3888
  }
3518
- <ng-content></ng-content>
3889
+ <ng-content />
3519
3890
  </span>
3520
3891
  `, isInline: true, styles: [":host{display:inline-block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3521
3892
  }
3522
3893
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfBadgeComponent, decorators: [{
3523
3894
  type: Component,
3524
3895
  args: [{ selector: 'af-badge', changeDetection: ChangeDetectionStrategy.OnPush, template: `
3525
- <span [class]="badgeClasses()">
3896
+ <span [class]="badgeClasses()" [attr.aria-label]="ariaLabel() || null">
3526
3897
  @if (icon()) {
3527
3898
  <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
3528
3899
  }
3529
3900
  @if (dot()) {
3530
3901
  <span class="ct-badge__dot" aria-hidden="true"></span>
3531
3902
  }
3532
- <ng-content></ng-content>
3903
+ <ng-content />
3533
3904
  </span>
3534
3905
  `, styles: [":host{display:inline-block}\n"] }]
3535
- }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], dot: [{ type: i0.Input, args: [{ isSignal: true, alias: "dot", required: false }] }] } });
3906
+ }], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], dot: [{ type: i0.Input, args: [{ isSignal: true, alias: "dot", required: false }] }] } });
3536
3907
 
3537
3908
  /**
3538
3909
  * Progress bar for showing completion state
@@ -4130,6 +4501,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4130
4501
  */
4131
4502
  class AfDrawerComponent {
4132
4503
  static nextId = 0;
4504
+ focusTrap = new FocusTrap();
4133
4505
  /** Two-way bindable open state */
4134
4506
  open = model(false, ...(ngDevMode ? [{ debugName: "open" }] : []));
4135
4507
  /** Slide-in position */
@@ -4149,8 +4521,6 @@ class AfDrawerComponent {
4149
4521
  /** Unique ID for aria-labelledby fallback */
4150
4522
  titleId = `af-drawer-title-${AfDrawerComponent.nextId++}`;
4151
4523
  panelRef = viewChild('panel', ...(ngDevMode ? [{ debugName: "panelRef" }] : []));
4152
- previousActiveElement = null;
4153
- focusableElements = [];
4154
4524
  containerClasses = computed(() => {
4155
4525
  const classes = ['ct-drawer'];
4156
4526
  const pos = this.position();
@@ -4174,7 +4544,7 @@ class AfDrawerComponent {
4174
4544
  }, ...(ngDevMode ? [{ debugName: "openEffect" }] : []));
4175
4545
  ngOnDestroy() {
4176
4546
  this.unlockBodyScroll();
4177
- this.restoreFocus();
4547
+ this.focusTrap.restoreFocus();
4178
4548
  }
4179
4549
  /** Closes the drawer and emits the closed event */
4180
4550
  close() {
@@ -4194,53 +4564,23 @@ class AfDrawerComponent {
4194
4564
  return;
4195
4565
  }
4196
4566
  if (event.key === 'Tab') {
4197
- this.trapFocus(event);
4567
+ const panel = this.panelRef()?.nativeElement;
4568
+ this.focusTrap.handleTab(event, panel, panel);
4198
4569
  }
4199
4570
  }
4200
4571
  onOpen() {
4201
- this.previousActiveElement = document.activeElement;
4572
+ this.focusTrap.saveFocus();
4202
4573
  this.lockBodyScroll();
4203
4574
  queueMicrotask(() => {
4204
4575
  if (!this.open())
4205
4576
  return;
4206
- this.refreshFocusableElements();
4207
- const first = this.focusableElements[0];
4208
- if (first) {
4209
- first.focus();
4210
- }
4211
- else {
4212
- this.panelRef()?.nativeElement.focus();
4213
- }
4577
+ const panel = this.panelRef()?.nativeElement;
4578
+ this.focusTrap.focusFirst(panel, panel);
4214
4579
  });
4215
4580
  }
4216
4581
  onClose() {
4217
4582
  this.unlockBodyScroll();
4218
- this.restoreFocus();
4219
- }
4220
- trapFocus(event) {
4221
- this.refreshFocusableElements();
4222
- if (this.focusableElements.length === 0) {
4223
- event.preventDefault();
4224
- this.panelRef()?.nativeElement.focus();
4225
- return;
4226
- }
4227
- const first = this.focusableElements[0];
4228
- const last = this.focusableElements[this.focusableElements.length - 1];
4229
- const active = document.activeElement;
4230
- if (event.shiftKey && active === first) {
4231
- event.preventDefault();
4232
- last.focus();
4233
- }
4234
- else if (!event.shiftKey && active === last) {
4235
- event.preventDefault();
4236
- first.focus();
4237
- }
4238
- }
4239
- restoreFocus() {
4240
- if (this.previousActiveElement) {
4241
- this.previousActiveElement.focus();
4242
- this.previousActiveElement = null;
4243
- }
4583
+ this.focusTrap.restoreFocus();
4244
4584
  }
4245
4585
  lockBodyScroll() {
4246
4586
  document.body.style.overflow = 'hidden';
@@ -4248,24 +4588,6 @@ class AfDrawerComponent {
4248
4588
  unlockBodyScroll() {
4249
4589
  document.body.style.overflow = '';
4250
4590
  }
4251
- refreshFocusableElements() {
4252
- const panel = this.panelRef()?.nativeElement;
4253
- if (!panel) {
4254
- this.focusableElements = [];
4255
- return;
4256
- }
4257
- const selectors = [
4258
- 'a[href]',
4259
- 'area[href]',
4260
- 'button:not([disabled])',
4261
- 'input:not([disabled])',
4262
- 'select:not([disabled])',
4263
- 'textarea:not([disabled])',
4264
- '[tabindex]:not([tabindex="-1"])',
4265
- ];
4266
- this.focusableElements = Array.from(panel.querySelectorAll(selectors.join(','))).filter((el) => !el.hasAttribute('disabled') &&
4267
- el.getAttribute('aria-hidden') !== 'true');
4268
- }
4269
4591
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDrawerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4270
4592
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfDrawerComponent, isStandalone: true, selector: "af-drawer", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, showCloseButton: { classPropertyName: "showCloseButton", publicName: "showCloseButton", isSignal: true, isRequired: false, transformFunction: null }, closeOnBackdropClick: { classPropertyName: "closeOnBackdropClick", publicName: "closeOnBackdropClick", isSignal: true, isRequired: false, transformFunction: null }, closeButtonAriaLabel: { classPropertyName: "closeButtonAriaLabel", publicName: "closeButtonAriaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", closed: "closed" }, host: { listeners: { "document:keydown": "onKeydown($event)" } }, viewQueries: [{ propertyName: "panelRef", first: true, predicate: ["panel"], descendants: true, isSignal: true }], ngImport: i0, template: `
4271
4593
  <div
@@ -5234,7 +5556,7 @@ class AfFileUploadComponent {
5234
5556
  {{ liveAnnouncement() }}
5235
5557
  </span>
5236
5558
  </div>
5237
- `, isInline: true, styles: [":host{display:block}.af-file-upload__sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"], dependencies: [{ kind: "component", type: AfButtonComponent, selector: "af-button", inputs: ["variant", "size", "type", "disabled", "iconOnly", "ariaLabel", "title"], outputs: ["clicked"] }, { kind: "component", type: AfBadgeComponent, selector: "af-badge", inputs: ["variant", "icon", "dot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5559
+ `, isInline: true, styles: [":host{display:block}.af-file-upload__sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"], dependencies: [{ kind: "component", type: AfButtonComponent, selector: "af-button", inputs: ["variant", "size", "type", "disabled", "iconOnly", "ariaLabel", "title"], outputs: ["clicked"] }, { kind: "component", type: AfBadgeComponent, selector: "af-badge", inputs: ["ariaLabel", "variant", "icon", "dot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5238
5560
  }
5239
5561
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfFileUploadComponent, decorators: [{
5240
5562
  type: Component,
@@ -6347,6 +6669,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6347
6669
  */
6348
6670
  class AfPopoverComponent {
6349
6671
  static nextId = 0;
6672
+ focusTrap = new FocusTrap();
6350
6673
  /** Two-way bindable open state. */
6351
6674
  open = model(false, ...(ngDevMode ? [{ debugName: "open" }] : []));
6352
6675
  /** Preferred position relative to the trigger. Flips automatically when space is insufficient. */
@@ -6370,8 +6693,6 @@ class AfPopoverComponent {
6370
6693
  wrapperRef = viewChild('wrapper', ...(ngDevMode ? [{ debugName: "wrapperRef" }] : []));
6371
6694
  contentRef = viewChild('popoverContent', ...(ngDevMode ? [{ debugName: "contentRef" }] : []));
6372
6695
  triggerDirective = contentChild(AfPopoverTriggerDirective, ...(ngDevMode ? [{ debugName: "triggerDirective" }] : []));
6373
- previousActiveElement = null;
6374
- focusableElements = [];
6375
6696
  flippedSide = signal(null, ...(ngDevMode ? [{ debugName: "flippedSide" }] : []));
6376
6697
  /** Effective side after auto-flip evaluation. */
6377
6698
  activeSide = computed(() => this.flippedSide() ?? this.position(), ...(ngDevMode ? [{ debugName: "activeSide" }] : []));
@@ -6392,7 +6713,7 @@ class AfPopoverComponent {
6392
6713
  }
6393
6714
  }, ...(ngDevMode ? [{ debugName: "openEffect" }] : []));
6394
6715
  ngOnDestroy() {
6395
- this.restoreFocus();
6716
+ this.focusTrap.restoreFocus();
6396
6717
  }
6397
6718
  /** Toggle the popover open state. */
6398
6719
  toggle() {
@@ -6421,59 +6742,29 @@ class AfPopoverComponent {
6421
6742
  return;
6422
6743
  const trigger = this.triggerDirective()?.elementRef.nativeElement;
6423
6744
  if (trigger) {
6424
- this.previousActiveElement = trigger;
6745
+ this.focusTrap.setReturnFocus(trigger);
6425
6746
  }
6426
6747
  this.close();
6427
6748
  }
6428
6749
  onKeydown(event) {
6429
6750
  if (!this.open() || event.key !== 'Tab')
6430
6751
  return;
6431
- this.trapFocus(event);
6752
+ const content = this.contentRef()?.nativeElement;
6753
+ this.focusTrap.handleTab(event, content, content);
6432
6754
  }
6433
6755
  onOpen() {
6434
- this.previousActiveElement = document.activeElement;
6756
+ this.focusTrap.saveFocus();
6435
6757
  this.flippedSide.set(this.computeFlippedSide());
6436
6758
  queueMicrotask(() => {
6437
6759
  if (!this.open())
6438
6760
  return;
6439
- this.refreshFocusableElements();
6440
- const first = this.focusableElements[0];
6441
- if (first) {
6442
- first.focus();
6443
- }
6444
- else {
6445
- this.contentRef()?.nativeElement.focus();
6446
- }
6761
+ const content = this.contentRef()?.nativeElement;
6762
+ this.focusTrap.focusFirst(content, content);
6447
6763
  });
6448
6764
  }
6449
6765
  onClose() {
6450
6766
  this.flippedSide.set(null);
6451
- this.restoreFocus();
6452
- }
6453
- restoreFocus() {
6454
- if (this.previousActiveElement) {
6455
- this.previousActiveElement.focus();
6456
- this.previousActiveElement = null;
6457
- }
6458
- }
6459
- trapFocus(event) {
6460
- this.refreshFocusableElements();
6461
- if (this.focusableElements.length === 0) {
6462
- event.preventDefault();
6463
- this.contentRef()?.nativeElement.focus();
6464
- return;
6465
- }
6466
- const first = this.focusableElements[0];
6467
- const last = this.focusableElements[this.focusableElements.length - 1];
6468
- const active = document.activeElement;
6469
- if (event.shiftKey && active === first) {
6470
- event.preventDefault();
6471
- last.focus();
6472
- }
6473
- else if (!event.shiftKey && active === last) {
6474
- event.preventDefault();
6475
- first.focus();
6476
- }
6767
+ this.focusTrap.restoreFocus();
6477
6768
  }
6478
6769
  computeFlippedSide() {
6479
6770
  const trigger = this.triggerDirective()?.elementRef.nativeElement;
@@ -6508,24 +6799,6 @@ class AfPopoverComponent {
6508
6799
  return opposite;
6509
6800
  return preferred;
6510
6801
  }
6511
- refreshFocusableElements() {
6512
- const content = this.contentRef()?.nativeElement;
6513
- if (!content) {
6514
- this.focusableElements = [];
6515
- return;
6516
- }
6517
- const selectors = [
6518
- 'a[href]',
6519
- 'area[href]',
6520
- 'button:not([disabled])',
6521
- 'input:not([disabled])',
6522
- 'select:not([disabled])',
6523
- 'textarea:not([disabled])',
6524
- '[tabindex]:not([tabindex="-1"])',
6525
- ];
6526
- this.focusableElements = Array.from(content.querySelectorAll(selectors.join(','))).filter((el) => !el.hasAttribute('disabled') &&
6527
- el.getAttribute('aria-hidden') !== 'true');
6528
- }
6529
6802
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfPopoverComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6530
6803
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfPopoverComponent, isStandalone: true, selector: "af-popover", inputs: { open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, align: { classPropertyName: "align", publicName: "align", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, showArrow: { classPropertyName: "showArrow", publicName: "showArrow", isSignal: true, isRequired: false, transformFunction: null }, closeOnClickOutside: { classPropertyName: "closeOnClickOutside", publicName: "closeOnClickOutside", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { open: "openChange", closed: "closed" }, host: { listeners: { "document:click": "onDocumentClick($event)", "document:keydown.escape": "onEscapeKey()", "document:keydown": "onKeydown($event)" } }, providers: [
6531
6804
  { provide: AF_POPOVER, useExisting: forwardRef(() => AfPopoverComponent) },
@@ -7385,5 +7658,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
7385
7658
  * Generated bundle index. Do not edit.
7386
7659
  */
7387
7660
 
7388
- export { AfAccordionComponent, AfAccordionItemComponent, AfAlertComponent, AfAvatarComponent, AfBadgeComponent, AfBannerComponent, AfBreadcrumbsComponent, AfButtonComponent, AfCardComponent, AfCellDefDirective, AfCheckboxComponent, AfChipInputComponent, AfComboboxComponent, AfDataTableComponent, AfDatepickerComponent, AfDividerComponent, AfDrawerComponent, AfDropdownComponent, AfEmptyStateComponent, AfFieldComponent, AfFileUploadComponent, AfFormatLabelPipe, AfIconComponent, AfInputComponent, AfModalComponent, AfNavItemComponent, AfNavbarComponent, AfPaginationComponent, AfPopoverComponent, AfPopoverTriggerDirective, AfProgressBarComponent, AfRadioComponent, AfSelectComponent, AfSelectMenuComponent, AfSidebarComponent, AfSkeletonComponent, AfSkipLinkComponent, AfSliderComponent, AfSpinnerComponent, AfSwitchComponent, AfTabPanelComponent, AfTableBodyComponent, AfTableCellComponent, AfTableComponent, AfTableHeaderCellComponent, AfTableHeaderComponent, AfTableRowComponent, AfTabsComponent, AfTextareaComponent, AfToastContainerComponent, AfToastService, AfToggleGroupComponent, AfToolbarComponent, AfTooltipDirective };
7661
+ export { AfAccordionComponent, AfAccordionItemComponent, AfAlertComponent, AfAvatarComponent, AfBadgeComponent, AfBannerComponent, AfBreadcrumbsComponent, AfButtonComponent, AfCardComponent, AfCellDefDirective, AfCheckboxComponent, AfChipInputComponent, AfComboboxComponent, AfDataTableComponent, AfDatepickerComponent, AfDividerComponent, AfDrawerComponent, AfDropdownComponent, AfEmptyStateComponent, AfFieldComponent, AfFileUploadComponent, AfFormatLabelPipe, AfIconComponent, AfInputComponent, AfModalComponent, AfNavItemComponent, AfNavbarComponent, AfPaginationComponent, AfPopoverComponent, AfPopoverTriggerDirective, AfProgressBarComponent, AfRadioComponent, AfRadioGroupComponent, AfSelectComponent, AfSelectMenuComponent, AfSidebarComponent, AfSkeletonComponent, AfSkipLinkComponent, AfSliderComponent, AfSpinnerComponent, AfSwitchComponent, AfTabPanelComponent, AfTableBodyComponent, AfTableCellComponent, AfTableComponent, AfTableHeaderCellComponent, AfTableHeaderComponent, AfTableRowComponent, AfTabsComponent, AfTextareaComponent, AfToastContainerComponent, AfToastService, AfToggleGroupComponent, AfToolbarComponent, AfTooltipDirective };
7389
7662
  //# sourceMappingURL=neuravision-ng-construct.mjs.map