@aquera/nile-elements 2.0.11 → 2.0.13

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.
Files changed (74) hide show
  1. package/README.md +7 -0
  2. package/demo/app-shell.html +21 -21
  3. package/dist/index.js +170 -167
  4. package/dist/nile-app-shell/nile-app-shell.cjs.js +1 -1
  5. package/dist/nile-app-shell/nile-app-shell.cjs.js.map +1 -1
  6. package/dist/nile-app-shell/nile-app-shell.css.cjs.js +1 -1
  7. package/dist/nile-app-shell/nile-app-shell.css.cjs.js.map +1 -1
  8. package/dist/nile-app-shell/nile-app-shell.css.esm.js +35 -32
  9. package/dist/nile-app-shell/nile-app-shell.esm.js +39 -34
  10. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.cjs.js +1 -1
  11. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.cjs.js.map +1 -1
  12. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.esm.js +3 -1
  13. package/dist/nile-select/nile-select.cjs.js +2 -2
  14. package/dist/nile-select/nile-select.cjs.js.map +1 -1
  15. package/dist/nile-select/nile-select.css.cjs.js +1 -1
  16. package/dist/nile-select/nile-select.css.cjs.js.map +1 -1
  17. package/dist/nile-select/nile-select.css.esm.js +12 -17
  18. package/dist/nile-select/nile-select.esm.js +43 -42
  19. package/dist/nile-select/virtual-scroll-helper.cjs.js +1 -1
  20. package/dist/nile-select/virtual-scroll-helper.cjs.js.map +1 -1
  21. package/dist/nile-select/virtual-scroll-helper.esm.js +2 -0
  22. package/dist/nile-virtual-select/nile-virtual-select.cjs.js +4 -4
  23. package/dist/nile-virtual-select/nile-virtual-select.cjs.js.map +1 -1
  24. package/dist/nile-virtual-select/nile-virtual-select.css.cjs.js +1 -1
  25. package/dist/nile-virtual-select/nile-virtual-select.css.cjs.js.map +1 -1
  26. package/dist/nile-virtual-select/nile-virtual-select.css.esm.js +12 -17
  27. package/dist/nile-virtual-select/nile-virtual-select.esm.js +26 -26
  28. package/dist/src/nile-app-shell/nile-app-shell-regions.test.js +9 -5
  29. package/dist/src/nile-app-shell/nile-app-shell-regions.test.js.map +1 -1
  30. package/dist/src/nile-app-shell/nile-app-shell.css.js +33 -30
  31. package/dist/src/nile-app-shell/nile-app-shell.css.js.map +1 -1
  32. package/dist/src/nile-app-shell/nile-app-shell.d.ts +22 -22
  33. package/dist/src/nile-app-shell/nile-app-shell.js +86 -76
  34. package/dist/src/nile-app-shell/nile-app-shell.js.map +1 -1
  35. package/dist/src/nile-app-shell/nile-app-shell.test.d.ts +1 -0
  36. package/dist/src/nile-app-shell/nile-app-shell.test.js +307 -89
  37. package/dist/src/nile-app-shell/nile-app-shell.test.js.map +1 -1
  38. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.css.js +3 -1
  39. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.css.js.map +1 -1
  40. package/dist/src/nile-select/nile-select.css.js +12 -17
  41. package/dist/src/nile-select/nile-select.css.js.map +1 -1
  42. package/dist/src/nile-select/nile-select.d.ts +18 -2
  43. package/dist/src/nile-select/nile-select.js +226 -64
  44. package/dist/src/nile-select/nile-select.js.map +1 -1
  45. package/dist/src/nile-select/nile-select.test.d.ts +1 -0
  46. package/dist/src/nile-select/nile-select.test.js +896 -17
  47. package/dist/src/nile-select/nile-select.test.js.map +1 -1
  48. package/dist/src/nile-select/virtual-scroll-helper.js +2 -0
  49. package/dist/src/nile-select/virtual-scroll-helper.js.map +1 -1
  50. package/dist/src/nile-virtual-select/nile-virtual-select.css.js +12 -17
  51. package/dist/src/nile-virtual-select/nile-virtual-select.css.js.map +1 -1
  52. package/dist/src/nile-virtual-select/nile-virtual-select.d.ts +10 -2
  53. package/dist/src/nile-virtual-select/nile-virtual-select.js +142 -30
  54. package/dist/src/nile-virtual-select/nile-virtual-select.js.map +1 -1
  55. package/dist/src/nile-virtual-select/nile-virtual-select.test.d.ts +1 -0
  56. package/dist/src/nile-virtual-select/nile-virtual-select.test.js +331 -16
  57. package/dist/src/nile-virtual-select/nile-virtual-select.test.js.map +1 -1
  58. package/dist/src/version.js +1 -1
  59. package/dist/src/version.js.map +1 -1
  60. package/dist/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +1 -1
  62. package/src/nile-app-shell/nile-app-shell-regions.test.ts +9 -5
  63. package/src/nile-app-shell/nile-app-shell.css.ts +33 -30
  64. package/src/nile-app-shell/nile-app-shell.test.ts +348 -105
  65. package/src/nile-app-shell/nile-app-shell.ts +89 -82
  66. package/src/nile-app-shell-panel/nile-app-shell-panel.css.ts +3 -1
  67. package/src/nile-select/nile-select.css.ts +12 -17
  68. package/src/nile-select/nile-select.test.ts +1112 -17
  69. package/src/nile-select/nile-select.ts +237 -67
  70. package/src/nile-select/virtual-scroll-helper.ts +2 -0
  71. package/src/nile-virtual-select/nile-virtual-select.css.ts +12 -17
  72. package/src/nile-virtual-select/nile-virtual-select.test.ts +404 -16
  73. package/src/nile-virtual-select/nile-virtual-select.ts +200 -79
  74. package/vscode-html-custom-data.json +36 -41
@@ -2,6 +2,7 @@ import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
2
2
  import { setViewport } from '@web/test-runner-commands';
3
3
  import './nile-app-shell';
4
4
  import '../nile-app-shell-nav-toggle/nile-app-shell-nav-toggle';
5
+ import '../nile-app-shell-panel/nile-app-shell-panel';
5
6
  import '../nile-nav-tab-group/nile-nav-tab-group';
6
7
  import '../nile-nav-tab/nile-nav-tab';
7
8
  import '../nile-side-bar/nile-side-bar';
@@ -270,6 +271,51 @@ describe('NileAppShell — Ask Aquera panel', () => {
270
271
  expect(part(el, 'panel-title')!.textContent!.trim()).to.equal('Ask Aquera');
271
272
  });
272
273
 
274
+ // Amendment A (A-8): Figma's Assistant Title is Text lg/Semibold, 18/28; the shell sets it on the title it renders itself.
275
+ it('17b. sets its own panel title at 18/28 in text-primary-900', async () => {
276
+ const tokens =
277
+ '--ng-font-size-text-lg: 18px; --ng-line-height-text-lg: 28px; --ng-font-weight-semibold: 600; --ng-colors-text-primary-900: rgb(24, 29, 39)';
278
+ const el = await sized(1440);
279
+ el.setAttribute('style', `${el.getAttribute('style')}; ${tokens}`);
280
+ el.openPanel();
281
+ await el.updateComplete;
282
+ const own = getComputedStyle(part(el, 'panel-title')!);
283
+ expect(own.fontSize).to.equal('18px');
284
+ expect(own.lineHeight).to.equal('28px');
285
+ expect(own.fontWeight).to.equal('600');
286
+ expect(own.color).to.equal('rgb(24, 29, 39)');
287
+ });
288
+
289
+ // A-8: a slotted nile-app-shell-panel (part of App Shell) draws the same Figma title: 18/28 SemiBold, text-primary-900.
290
+ it('17c. sets the nile-app-shell-panel title at 18/28 in text-primary-900', async () => {
291
+ const tokens =
292
+ '--ng-font-size-text-sm: 14px; --ng-font-size-text-lg: 18px; --ng-line-height-text-lg: 28px; --ng-font-weight-semibold: 600; --ng-colors-text-primary-900: rgb(24, 29, 39)';
293
+ const slotted = await fixture<HTMLElement>(html`
294
+ <nile-app-shell-panel label="Ask Aquera" style="${tokens}"></nile-app-shell-panel>
295
+ `);
296
+ await (slotted as unknown as { updateComplete: Promise<unknown> }).updateComplete;
297
+ const style = getComputedStyle(slotted.shadowRoot!.querySelector('.title') as HTMLElement);
298
+ expect(style.fontSize).to.equal('18px');
299
+ expect(style.lineHeight).to.equal('28px');
300
+ expect(style.fontWeight).to.equal('600');
301
+ expect(style.color).to.equal('rgb(24, 29, 39)');
302
+ });
303
+
304
+ // The enterprise sheet keeps its own panel title: --nile-type-scale-3 and an inherited line height, exactly as before.
305
+ // Enterprise defines no --ng-* tokens, so the NxtGen line height does not apply there.
306
+ it('17d. leaves the enterprise nile-app-shell-panel title as it was', async () => {
307
+ const slotted = await fixture<HTMLElement>(html`
308
+ <nile-app-shell-panel
309
+ label="Ask Aquera"
310
+ style="--nile-type-scale-3: 14px; line-height: 20px"
311
+ ></nile-app-shell-panel>
312
+ `);
313
+ await (slotted as unknown as { updateComplete: Promise<unknown> }).updateComplete;
314
+ const style = getComputedStyle(slotted.shadowRoot!.querySelector('.title') as HTMLElement);
315
+ expect(style.fontSize).to.equal('14px');
316
+ expect(style.lineHeight).to.equal('20px');
317
+ });
318
+
273
319
  it('18. Cmd/Ctrl+J toggles the panel', async () => {
274
320
  const el = await sized(1440);
275
321
  document.dispatchEvent(
@@ -690,6 +736,53 @@ describe('NileAppShell — the built-in Ask button', () => {
690
736
  expect(actions.getBoundingClientRect().width).to.be.greaterThan(0);
691
737
  });
692
738
 
739
+ // Amendment A (M8): the design's Ask is the secondary button, not the filled primary.
740
+ it('34b. renders Ask as the secondary variant', async () => {
741
+ const el = await sized(1440);
742
+ expect(ask(el)!.getAttribute('variant')).to.equal('secondary');
743
+ });
744
+
745
+ // Amendment A (M9, M10): app actions, a 16px divider, then Ask, 16px apart; with no app actions, no divider.
746
+ it('34c. draws a divider before Ask only when the page slots actions, 16px from both', async () => {
747
+ const alone = await sized(1440);
748
+ await settled(alone);
749
+ expect(part(alone, 'ask-rule')).to.be.null;
750
+
751
+ const el = await fixture<NileAppShell>(html`
752
+ <nile-app-shell style="width: 1440px; height: 600px; --ng-spacing-xl: 16px;">
753
+ <nav slot="side-nav">nav</nav>
754
+ <span slot="top-bar">Title</span>
755
+ <button slot="top-bar-actions" id="one">One</button>
756
+ <button slot="top-bar-actions" id="two">Two</button>
757
+ <p>page</p>
758
+ <div slot="panel">assistant</div>
759
+ </nile-app-shell>
760
+ `);
761
+ await waitUntil(() => el.hasAttribute('has-top-bar-actions'));
762
+ await el.updateComplete;
763
+
764
+ const rule = part(el, 'ask-rule')!;
765
+ expect(rule).to.exist;
766
+ const one = el.querySelector('#one')!.getBoundingClientRect();
767
+ const two = el.querySelector('#two')!.getBoundingClientRect();
768
+ const ruleBox = rule.getBoundingClientRect();
769
+ const askBox = ask(el)!.getBoundingClientRect();
770
+
771
+ expect(Math.round(ruleBox.height)).to.equal(16);
772
+ expect(Math.round(two.left - one.right)).to.equal(16);
773
+ expect(Math.round(ruleBox.left - two.right)).to.equal(16);
774
+ expect(Math.round(askBox.left - ruleBox.left)).to.equal(16);
775
+
776
+ // Order: the page's actions, the divider, then Ask.
777
+ /* eslint-disable-next-line no-bitwise */
778
+ const ruleBeforeAsk = rule.compareDocumentPosition(ask(el)!) & Node.DOCUMENT_POSITION_FOLLOWING;
779
+ expect(ruleBeforeAsk).to.be.greaterThan(0);
780
+
781
+ el.noAsk = true;
782
+ await el.updateComplete;
783
+ expect(part(el, 'ask-rule'), 'no Ask, no divider').to.be.null;
784
+ });
785
+
693
786
  // The shell draws its icon controls as bare glyphs via ::part(base), overriding nile-button's circle and ghost surface.
694
787
  const utilityBase = (el: NileAppShell, name: string) =>
695
788
  part(el, name)!.shadowRoot!.querySelector('[part~="base"]') as HTMLElement;
@@ -932,6 +1025,7 @@ const HEADER_TOKENS = [
932
1025
  '--ng-spacing-xxs: 2px',
933
1026
  '--ng-spacing-xs: 4px',
934
1027
  '--ng-spacing-md: 8px',
1028
+ '--ng-spacing-md-alt: 10px',
935
1029
  '--ng-spacing-lg: 12px',
936
1030
  '--ng-spacing-xl: 16px',
937
1031
  '--ng-spacing-3xl: 24px',
@@ -943,15 +1037,23 @@ const HEADER_TOKENS = [
943
1037
  '--ng-line-height-text-md: 24px',
944
1038
  ].join('; ');
945
1039
 
946
- type Leading = 'none' | 'back' | 'app-icon';
1040
+ // What sits before the title: `headerBack` and the `app-icon` slot are independent, so the four combinations are named here for looping.
1041
+ type Leading = 'none' | 'back' | 'app-icon' | 'back+app-icon';
1042
+
1043
+ const LEADINGS: readonly Leading[] = ['none', 'back', 'app-icon', 'back+app-icon'];
1044
+
1045
+ const hasBack = (leading?: Leading) => leading === 'back' || leading === 'back+app-icon';
1046
+ const hasIcon = (leading?: Leading) =>
1047
+ leading === 'app-icon' || leading === 'back+app-icon';
947
1048
 
948
1049
  interface HeaderAxes {
949
1050
  pageTitle?: string;
950
1051
  description?: string;
951
1052
  badge?: string;
952
1053
  leading?: Leading;
953
- backLabel?: string;
1054
+ headerBackLabel?: string;
954
1055
  tabs?: boolean;
1056
+ actions?: boolean;
955
1057
  }
956
1058
 
957
1059
  // A shell carrying whatever header axes a test asks for; an unset axis is the empty string, meaning "absent".
@@ -962,13 +1064,16 @@ const header = async (axes: HeaderAxes = {}) => {
962
1064
  .pageTitle=${axes.pageTitle ?? ''}
963
1065
  .description=${axes.description ?? ''}
964
1066
  .badge=${axes.badge ?? ''}
965
- leading=${axes.leading ?? 'none'}
966
- .backLabel=${axes.backLabel ?? 'Back'}
1067
+ .headerBack=${hasBack(axes.leading)}
1068
+ .headerBackLabel=${axes.headerBackLabel ?? 'Back'}
967
1069
  >
968
1070
  <nav slot="side-nav">nav</nav>
969
- ${axes.leading === 'app-icon'
1071
+ ${hasIcon(axes.leading)
970
1072
  ? html`<span slot="app-icon" id="mark">A</span>`
971
1073
  : ''}
1074
+ ${axes.actions
1075
+ ? html`<button slot="top-bar-actions" id="cta">Button CTA</button>`
1076
+ : ''}
972
1077
  ${axes.tabs
973
1078
  ? html`<nile-nav-tab-group slot="tabs">
974
1079
  <nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
@@ -979,6 +1084,8 @@ const header = async (axes: HeaderAxes = {}) => {
979
1084
  </nile-app-shell>
980
1085
  `);
981
1086
  if (axes.tabs) await waitUntil(() => el.hasAttribute('has-tabs'));
1087
+ if (hasIcon(axes.leading)) await waitUntil(() => el.hasAttribute('has-app-icon'));
1088
+ if (axes.actions) await waitUntil(() => el.hasAttribute('has-top-bar-actions'));
982
1089
  await el.updateComplete;
983
1090
  return el;
984
1091
  };
@@ -987,7 +1094,8 @@ const header = async (axes: HeaderAxes = {}) => {
987
1094
  describe('NileAppShell — the header title block', () => {
988
1095
  it('44. draws no title block at all until an axis asks for one', async () => {
989
1096
  const el = await header();
990
- expect(el.leading).to.equal('none');
1097
+ expect(el.headerBack).to.be.false;
1098
+ expect(el.hasAttribute('has-app-icon')).to.be.false;
991
1099
  expect(part(el, 'title-area')).to.be.null;
992
1100
  expect(part(el, 'page-title')).to.be.null;
993
1101
  expect(part(el, 'description')).to.be.null;
@@ -1029,8 +1137,8 @@ describe('NileAppShell — the header title block', () => {
1029
1137
  expect(el.hasAttribute('has-description')).to.be.true;
1030
1138
  });
1031
1139
 
1032
- // Leading is the exception: a back control stands on its own but brings no empty title stack.
1033
- it('75. mounts a leading control on its own, without an empty title stack', async () => {
1140
+ // The back arrow is the exception: it stands on its own but brings no empty title stack.
1141
+ it('75. mounts the back arrow on its own, without an empty title stack', async () => {
1034
1142
  const el = await header({ leading: 'back' });
1035
1143
 
1036
1144
  expect(part(el, 'title-area')).to.exist;
@@ -1232,153 +1340,176 @@ describe('NileAppShell — the header title block', () => {
1232
1340
  expect(part(el, 'description')).to.be.null;
1233
1341
  });
1234
1342
 
1235
- it('50. leading defaults to none and draws neither control', async () => {
1343
+ it('50. headerBack defaults to false and draws no arrow, and no slotted icon draws no icon', async () => {
1236
1344
  const el = await header({ pageTitle: 'Applications' });
1237
- expect(el.leading).to.equal('none');
1345
+ expect(el.headerBack).to.be.false;
1346
+ expect(el.hasAttribute('headerback')).to.be.false;
1238
1347
  expect(part(el, 'back')).to.be.null;
1239
- expect(part(el, 'app-icon')).to.be.null;
1348
+ expect(el.hasAttribute('has-app-icon')).to.be.false;
1349
+ expect(getComputedStyle(part(el, 'app-icon')!).display).to.equal('none');
1240
1350
  expect(el.shadowRoot!.querySelector('.crumb-divider')).to.be.null;
1241
1351
  });
1242
1352
 
1243
- // Leading="back" draws a breadcrumb: glyph, previous-page label, slash, then the page title.
1244
- it('51. leading="back" draws glyph, label, divider, then the title', async () => {
1353
+ // Amendment A: the back control is the arrow alone, with no previous-page label and no slash.
1354
+ it('51. headerBack draws the narrow-left arrow only, then the title', async () => {
1245
1355
  const el = await header({ pageTitle: 'Applications', leading: 'back' });
1246
1356
  const back = part(el, 'back')!;
1247
- const divider = el.shadowRoot!.querySelector(
1248
- '.crumb-divider'
1249
- ) as HTMLElement;
1250
1357
  const title = part(el, 'page-title')!;
1251
1358
 
1252
1359
  expect(back).to.exist;
1253
1360
  expect(back.tagName).to.equal('BUTTON');
1254
1361
  expect(back.getAttribute('type')).to.equal('button');
1255
- expect(divider).to.exist;
1256
- expect(divider.getAttribute('aria-hidden')).to.equal('true');
1257
1362
 
1258
1363
  // The icons go through nile-glyph, so assert the glyph, not an <svg>.
1259
- const chevron = back.querySelector('nile-glyph')!;
1260
- expect(chevron).to.exist;
1261
- expect(chevron.getAttribute('name')).to.equal('ng-chevron-left');
1262
- expect(divider.querySelector('nile-glyph')!.getAttribute('name')).to.equal(
1263
- 'ng-slash'
1264
- );
1364
+ const glyphs = back.querySelectorAll('nile-glyph');
1365
+ expect(glyphs.length).to.equal(1);
1366
+ expect(glyphs[0].getAttribute('name')).to.equal('ng-arrow-narrow-left');
1265
1367
 
1266
- // glyph before label, inside the control.
1267
- const label = back.querySelector('span')!;
1268
- /* eslint-disable-next-line no-bitwise */
1269
- const glyphIsFirst =
1270
- chevron.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING;
1271
- expect(glyphIsFirst).to.be.greaterThan(0);
1272
-
1273
- // control, then divider, then title.
1274
- for (const [first, second] of [
1275
- [back, divider],
1276
- [divider, title],
1277
- ]) {
1278
- /* eslint-disable-next-line no-bitwise */
1279
- const ordered =
1280
- first.compareDocumentPosition(second) &
1281
- Node.DOCUMENT_POSITION_FOLLOWING;
1282
- expect(ordered).to.be.greaterThan(0);
1283
- }
1368
+ // No label, no divider, no slash anywhere in the header.
1369
+ expect(back.textContent!.trim()).to.equal('');
1370
+ expect(back.querySelector('span')).to.be.null;
1371
+ expect(el.shadowRoot!.querySelector('.crumb-divider')).to.be.null;
1372
+ expect(el.shadowRoot!.querySelector('nile-glyph[name="ng-slash"]')).to.be.null;
1284
1373
 
1285
- // nile-glyph is not registered on this test page, so the divider has no box to measure; only that the leading axis has not switched it off.
1374
+ /* eslint-disable-next-line no-bitwise */
1375
+ const backIsFirst =
1376
+ back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING;
1377
+ expect(backIsFirst).to.be.greaterThan(0);
1286
1378
  expect(back.getBoundingClientRect().width).to.be.greaterThan(0);
1287
- expect(getComputedStyle(divider).display).to.not.equal('none');
1288
1379
  });
1289
1380
 
1290
- // The slash separates the back control from the page title, so there is nothing to point at until a title is set.
1291
- it('51b. leading="back" without a title draws no divider', async () => {
1292
- const el = await header({ leading: 'back' });
1381
+ // camelCase property, attribute: true + reflect: true — the title-block pattern.
1382
+ it('51b. headerBack and headerBackLabel work from markup and reflect to their attributes', async () => {
1383
+ const marked = await fixture<NileAppShell>(html`
1384
+ <nile-app-shell pageTitle="Applications" headerBack headerBackLabel="Retour">
1385
+ <p>page</p>
1386
+ </nile-app-shell>
1387
+ `);
1388
+ await marked.updateComplete;
1389
+ expect(marked.headerBack).to.be.true;
1390
+ expect(marked.headerBackLabel).to.equal('Retour');
1391
+ expect(part(marked, 'back')).to.exist;
1392
+
1393
+ const el = await header({ pageTitle: 'Applications' });
1394
+ el.headerBack = true;
1395
+ el.headerBackLabel = 'Go back';
1396
+ await el.updateComplete;
1397
+ expect(el.hasAttribute('headerback')).to.be.true;
1398
+ expect(el.getAttribute('headerbacklabel')).to.equal('Go back');
1293
1399
  expect(part(el, 'back')).to.exist;
1294
- expect(el.shadowRoot!.querySelector('.crumb-divider')).to.be.null;
1295
1400
 
1296
- el.pageTitle = 'Applications';
1401
+ el.headerBack = false;
1297
1402
  await el.updateComplete;
1298
- expect(el.shadowRoot!.querySelector('.crumb-divider')).to.exist;
1403
+ expect(el.hasAttribute('headerback')).to.be.false;
1404
+ expect(part(el, 'back')).to.be.null;
1299
1405
  });
1300
1406
 
1301
- it('52. takes the previous-page label from backLabel', async () => {
1407
+ // headerBackLabel names the control for assistive tech and is never drawn as text.
1408
+ it('52. headerBackLabel is the accessible name only, never visible text', async () => {
1302
1409
  const el = await header({ pageTitle: 'Applications', leading: 'back' });
1303
- // The label is the control's accessible name, so it lives in the text.
1304
- expect(part(el, 'back')!.textContent!.trim()).to.equal('Back');
1410
+ const back = part(el, 'back')!;
1411
+ expect(back.getAttribute('aria-label')).to.equal('Back');
1412
+ expect(back.getAttribute('title')).to.equal('Back');
1413
+ expect(back.querySelector('nile-glyph')!.getAttribute('aria-hidden')).to.not.equal('false');
1305
1414
 
1306
- el.backLabel = 'Integrations';
1415
+ el.headerBackLabel = 'Integrations';
1307
1416
  await el.updateComplete;
1308
- expect(part(el, 'back')!.textContent!.trim()).to.equal('Integrations');
1417
+ expect(back.getAttribute('aria-label')).to.equal('Integrations');
1418
+ // Not rendered anywhere in the header as text.
1419
+ expect(back.textContent!.trim()).to.equal('');
1420
+ expect(part(el, 'title-area')!.textContent!.includes('Integrations')).to.be.false;
1421
+ expect(part(el, 'top-bar')!.textContent!.includes('Integrations')).to.be.false;
1309
1422
  });
1310
1423
 
1311
- // The shell owns no history, so the control only announces the press; the page navigates itself.
1312
- it('53. the back control emits nile-header-back, as do goBackHeader() and goBackPage()', async () => {
1424
+ // The shell owns no history, so the control only announces the press, as the standard nile-click; the page navigates itself.
1425
+ it('53. the back control and goBackHeader() emit nile-click { value: "back" } from the shell', async () => {
1313
1426
  const el = await header({ pageTitle: 'Applications', leading: 'back' });
1427
+ let retired = 0;
1428
+ el.addEventListener('nile-header-back', () => {
1429
+ retired += 1;
1430
+ });
1314
1431
 
1315
- const clicked = oneEvent(el, 'nile-header-back');
1432
+ const clicked = oneEvent(el, 'nile-click');
1316
1433
  (part(el, 'back') as HTMLButtonElement).click();
1317
- const fromClick = await clicked;
1318
- expect(fromClick).to.exist;
1434
+ const fromClick = (await clicked) as CustomEvent;
1435
+ expect(fromClick.detail).to.deep.equal({ value: 'back' });
1436
+ expect(fromClick.target).to.equal(el);
1437
+ expect(fromClick.bubbles).to.be.true;
1438
+ expect(fromClick.composed).to.be.true;
1319
1439
 
1320
- const called = oneEvent(el, 'nile-header-back');
1440
+ const called = oneEvent(el, 'nile-click');
1321
1441
  el.goBackHeader();
1322
- expect(await called).to.exist;
1442
+ const fromCall = (await called) as CustomEvent;
1443
+ expect(fromCall.detail).to.deep.equal({ value: 'back' });
1444
+ expect(fromCall.target).to.equal(el);
1323
1445
 
1324
- // goBackPage() is the deprecated alias of goBackHeader(): same event.
1325
- const aliased = oneEvent(el, 'nile-header-back');
1326
- el.goBackPage();
1327
- expect(await aliased).to.exist;
1446
+ // nile-header-back and goBackPage() are gone in 2.0.12 (Amendment A, A-12).
1447
+ expect(retired).to.equal(0);
1448
+ expect((el as unknown as { goBackPage?: unknown }).goBackPage).to.be.undefined;
1328
1449
 
1329
1450
  // It is the page's event, not the panel's: nothing about the panel moves.
1330
1451
  expect(el.panelOpen).to.be.false;
1331
1452
  });
1332
1453
 
1333
- // The Description axis must not move when the Leading axis changes, so the description is always the stack's second line under the title.
1334
- it('63. aligns the description to the page title in every leading mode', async () => {
1335
- for (const leading of ['none', 'back', 'app-icon'] as const) {
1454
+ // A nile-click bubbling up from page content must not read as the header back press.
1455
+ it('53b. a nile-click from slotted content is distinguishable by its target', async () => {
1456
+ const el = await header({ pageTitle: 'Applications', leading: 'back' });
1457
+ const page = el.querySelector('p')!;
1458
+ const seen: EventTarget[] = [];
1459
+ el.addEventListener('nile-click', e => seen.push(e.target!));
1460
+ page.dispatchEvent(
1461
+ new CustomEvent('nile-click', {
1462
+ bubbles: true,
1463
+ composed: true,
1464
+ detail: { value: 'back' },
1465
+ })
1466
+ );
1467
+ (part(el, 'back') as HTMLButtonElement).click();
1468
+ expect(seen).to.deep.equal([page, el]);
1469
+ });
1470
+
1471
+ // The four combinations are independent; with an app icon the description sits under the icon, otherwise under the title.
1472
+ it('63. aligns the description under the app icon when there is one, else the title', async () => {
1473
+ for (const leading of LEADINGS) {
1336
1474
  const el = await header({
1337
1475
  pageTitle: 'Applications',
1338
1476
  description: 'Manage the apps connected to this tenant',
1339
1477
  leading,
1340
1478
  });
1341
1479
 
1342
- const title = part(el, 'page-title')!.getBoundingClientRect();
1480
+ const anchor = hasIcon(leading) ? part(el, 'app-icon')! : part(el, 'page-title')!;
1343
1481
  const description = part(el, 'description')!.getBoundingClientRect();
1344
1482
  expect(Math.round(description.left), leading).to.equal(
1345
- Math.round(title.left)
1483
+ Math.round(anchor.getBoundingClientRect().left)
1346
1484
  );
1347
1485
  }
1348
1486
  });
1349
1487
 
1350
- // Neither leading control lives in the title row, so neither can displace the title away from the description.
1351
- it('64. renders both leading modes beside the title stack, not in the row', async () => {
1352
- const backShell = await header({ pageTitle: 'Applications', leading: 'back' });
1353
- const iconShell = await header({
1354
- pageTitle: 'Applications',
1355
- leading: 'app-icon',
1356
- });
1357
-
1358
- for (const [el, name] of [
1359
- [backShell, 'back'],
1360
- [iconShell, 'app-icon'],
1361
- ] as const) {
1362
- const control = part(el, name)!;
1488
+ // The arrow sits beside the stack; the app icon is the first thing in the title row.
1489
+ it('64. renders the arrow beside the title stack and the app icon inside the title row', async () => {
1490
+ for (const leading of LEADINGS) {
1491
+ const el = await header({ pageTitle: 'Applications', leading });
1363
1492
  const row = el.shadowRoot!.querySelector('.title-row') as HTMLElement;
1364
1493
  const area = part(el, 'title-area')!;
1365
- expect(row.contains(control), name).to.be.false;
1366
- expect(area.contains(control), name).to.be.true;
1494
+ const back = part(el, 'back');
1495
+ const icon = part(el, 'app-icon')!;
1496
+
1497
+ expect(!!back, `${leading} back`).to.equal(hasBack(leading));
1498
+ expect(el.hasAttribute('has-app-icon'), `${leading} icon`).to.equal(hasIcon(leading));
1499
+ if (back) {
1500
+ expect(row.contains(back), leading).to.be.false;
1501
+ expect(area.contains(back), leading).to.be.true;
1502
+ }
1503
+ expect(row.contains(icon), leading).to.be.true;
1504
+ if (hasIcon(leading)) {
1505
+ expect(row.firstElementChild, leading).to.equal(icon);
1506
+ }
1367
1507
  }
1368
-
1369
- // The breadcrumb divider travels with the control it belongs to.
1370
- const divider = backShell.shadowRoot!.querySelector(
1371
- '.crumb-divider'
1372
- ) as HTMLElement;
1373
- const row = backShell.shadowRoot!.querySelector(
1374
- '.title-row'
1375
- ) as HTMLElement;
1376
- expect(row.contains(divider)).to.be.false;
1377
1508
  });
1378
1509
 
1379
- // The leading element sits on the title's row, and its offset does not move when a description appears.
1380
- it('65. sits the leading element on the title row, description or not', async () => {
1381
- for (const leading of ['back', 'app-icon'] as const) {
1510
+ // The arrow sits on the title's row, and its offset does not move when a description appears.
1511
+ it('65. sits the back arrow on the title row, description or not', async () => {
1512
+ for (const leading of ['back', 'back+app-icon'] as const) {
1382
1513
  for (const description of ['', 'Manage the apps connected here']) {
1383
1514
  const el = await header({
1384
1515
  pageTitle: 'Applications',
@@ -1387,9 +1518,7 @@ describe('NileAppShell — the header title block', () => {
1387
1518
  });
1388
1519
 
1389
1520
  const title = part(el, 'page-title')!.getBoundingClientRect();
1390
- const box = el.shadowRoot!.querySelector(
1391
- '.leading'
1392
- ) as HTMLElement;
1521
+ const box = el.shadowRoot!.querySelector('.back-line') as HTMLElement;
1393
1522
  const label = `${leading} description=${!!description}`;
1394
1523
 
1395
1524
  // The line box is the title's own, and it starts where the title does.
@@ -1403,6 +1532,120 @@ describe('NileAppShell — the header title block', () => {
1403
1532
  }
1404
1533
  });
1405
1534
 
1535
+ // Figma Title Area: arrow → 16px → [icon → 8px → title]; Breadcrumb / 01: toggle glyph → 16px → divider (16px tall) → 16px.
1536
+ it('65b. spaces the toggle, divider, arrow, icon and title as the design does', async () => {
1537
+ const el = await header({ pageTitle: 'Applications', leading: 'back+app-icon' });
1538
+ const toggle = part(el, 'nav-toggle')!;
1539
+ const glyph = toggle.querySelector('nile-glyph') ?? toggle;
1540
+ const rule = part(el, 'rule')!.getBoundingClientRect();
1541
+ const backGlyph = part(el, 'back')!.querySelector('nile-glyph')!.getBoundingClientRect();
1542
+ const icon = part(el, 'app-icon')!.getBoundingClientRect();
1543
+ const title = part(el, 'page-title')!.getBoundingClientRect();
1544
+ const toggleGlyph = glyph.getBoundingClientRect();
1545
+
1546
+ expect(Math.round(rule.height)).to.equal(16);
1547
+ expect(Math.round(rule.left - toggleGlyph.right)).to.equal(16);
1548
+ expect(Math.round(backGlyph.left - rule.left)).to.equal(16);
1549
+ expect(Math.round(backGlyph.width)).to.equal(20);
1550
+ expect(Math.round(icon.left - backGlyph.right)).to.equal(16);
1551
+ expect(Math.round(title.left - icon.right)).to.equal(8);
1552
+
1553
+ // Without an icon the title takes the icon's place, 16px after the arrow.
1554
+ const plain = await header({ pageTitle: 'Applications', leading: 'back' });
1555
+ const plainArrow = part(plain, 'back')!.querySelector('nile-glyph')!.getBoundingClientRect();
1556
+ const plainTitle = part(plain, 'page-title')!.getBoundingClientRect();
1557
+ expect(Math.round(plainTitle.left - plainArrow.right)).to.equal(16);
1558
+ });
1559
+
1560
+ // The app-icon slot works with or without headerBack, and with or without a title.
1561
+ it('65c. shows a slotted app icon independently of headerBack', async () => {
1562
+ const iconOnly = await header({ pageTitle: 'Applications', leading: 'app-icon' });
1563
+ expect(part(iconOnly, 'back')).to.be.null;
1564
+ expect(part(iconOnly, 'app-icon')!.getBoundingClientRect().width).to.equal(20);
1565
+
1566
+ // An icon on its own still mounts the title block.
1567
+ const bare = await fixture<NileAppShell>(html`
1568
+ <nile-app-shell style="width: 1176px; height: 800px; ${HEADER_TOKENS}">
1569
+ <nav slot="side-nav">nav</nav>
1570
+ <span slot="app-icon" id="mark">A</span>
1571
+ <p>page</p>
1572
+ </nile-app-shell>
1573
+ `);
1574
+ await waitUntil(() => bare.hasAttribute('has-app-icon'));
1575
+ await bare.updateComplete;
1576
+ expect(part(bare, 'title-area')).to.exist;
1577
+ expect(part(bare, 'app-icon')!.getBoundingClientRect().width).to.equal(20);
1578
+
1579
+ // Removing the mark takes the icon (and the block it alone mounted) away again.
1580
+ bare.querySelector('#mark')!.remove();
1581
+ await waitUntil(() => !bare.hasAttribute('has-app-icon'));
1582
+ await bare.updateComplete;
1583
+ expect(part(bare, 'title-area')).to.be.null;
1584
+ });
1585
+
1586
+ // Amendment A (M11): the inactive tab colour is scoped to the shell's strip; a standalone group keeps its defaults.
1587
+ it('65e. colours the inactive header tabs through the shell only', async () => {
1588
+ const colours = [
1589
+ '--ng-colors-fg-brand-primary-alt: rgb(21, 94, 239)',
1590
+ '--ng-colors-text-brand-secondary-700: rgb(0, 78, 235)',
1591
+ '--ng-colors-text-quaternary-500: rgb(113, 118, 128)',
1592
+ '--ng-colors-text-secondary-700: rgb(65, 70, 81)',
1593
+ ].join('; ');
1594
+ const el = await header({ pageTitle: 'Applications', tabs: true });
1595
+ el.setAttribute('style', `${el.getAttribute('style')}; ${colours}`);
1596
+ await el.updateComplete;
1597
+
1598
+ const group = el.querySelector('nile-nav-tab-group') as HTMLElement;
1599
+ const inactive = el.querySelectorAll('nile-nav-tab')[1] as HTMLElement;
1600
+ // Known limitation (M12, A-7): the shell sets no indicator colour, because nile-nav-tab-group's top indicator
1601
+ // hard-codes its token in its own shadow tree; the indicator keeps the group's default.
1602
+ const indicatorOf = (g: HTMLElement) =>
1603
+ getComputedStyle(g.shadowRoot!.querySelector('.nav-tab-group__indicator') as HTMLElement)
1604
+ .borderBottomColor;
1605
+ expect(indicatorOf(group)).to.equal('rgb(0, 78, 235)');
1606
+ // The inactive label is remapped inside the shell only: nile-nav-tab itself is unchanged and still paints
1607
+ // var(--nile-colors-dark-500, var(--ng-colors-text-secondary-700)); the shell points that fallback token at quaternary-500.
1608
+ // (The painted ink sits inside nile-link, which this runner does not render; it is checked in the browser instead.)
1609
+ const secondaryOf = (node: HTMLElement) =>
1610
+ getComputedStyle(node).getPropertyValue('--ng-colors-text-secondary-700').trim();
1611
+ expect(secondaryOf(inactive)).to.equal('rgb(113, 118, 128)');
1612
+ type Sheets = { cssText: string } | { cssText: string }[];
1613
+ const sheets = (customElements.get('nile-nav-tab') as unknown as { styles: Sheets }).styles;
1614
+ const tabStyles = ([] as { cssText: string }[]).concat(sheets).map(sheet => sheet.cssText).join('\n');
1615
+ expect(tabStyles).to.include(
1616
+ 'color: var(--nile-colors-dark-500, var(--ng-colors-text-secondary-700));'
1617
+ );
1618
+ expect(tabStyles, 'no new hook on the shared component').to.not.include('--nile-nav-tab-text-color:');
1619
+ // Nothing else in the shell's tree is remapped.
1620
+ expect(secondaryOf(el)).to.equal('rgb(65, 70, 81)');
1621
+
1622
+ // Outside the shell nothing changes.
1623
+ const standalone = await fixture<HTMLElement>(html`
1624
+ <nile-nav-tab-group style="${colours}">
1625
+ <nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
1626
+ <nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
1627
+ </nile-nav-tab-group>
1628
+ `);
1629
+ const loneTab = standalone.querySelectorAll('nile-nav-tab')[1] as HTMLElement;
1630
+ expect(secondaryOf(loneTab), 'the global default is untouched').to.equal('rgb(65, 70, 81)');
1631
+ await (standalone as unknown as { updateComplete: Promise<unknown> }).updateComplete;
1632
+ expect(indicatorOf(standalone), 'a standalone group paints its own default').to.equal(
1633
+ 'rgb(0, 78, 235)'
1634
+ );
1635
+ });
1636
+
1637
+ it('65d. colours the description text-quaternary-500', async () => {
1638
+ const el = await header({
1639
+ pageTitle: 'Applications',
1640
+ description: 'Manage the apps connected here',
1641
+ });
1642
+ el.style.setProperty('--ng-colors-text-quaternary-500', 'rgb(113, 118, 128)');
1643
+ el.style.setProperty('--ng-colors-text-tertiary-600', 'rgb(1, 2, 3)');
1644
+ expect(getComputedStyle(part(el, 'description')!).color).to.equal(
1645
+ 'rgb(113, 118, 128)'
1646
+ );
1647
+ });
1648
+
1406
1649
  it('66. centres a 20px app icon in the 24px title row, two pixels down', async () => {
1407
1650
  const el = await header({
1408
1651
  pageTitle: 'Applications',
@@ -1439,7 +1682,7 @@ describe('NileAppShell — the header title block', () => {
1439
1682
  expect(base.getBoundingClientRect().width).to.equal(32);
1440
1683
  });
1441
1684
 
1442
- it('54. leading="app-icon" draws a 20px block around the slotted mark', async () => {
1685
+ it('54. a slotted app icon draws a 20px block around the mark', async () => {
1443
1686
  const el = await header({ pageTitle: 'Applications', leading: 'app-icon' });
1444
1687
  const box = part(el, 'app-icon')!;
1445
1688
  expect(box).to.exist;
@@ -1495,7 +1738,7 @@ describe('NileAppShell — the four header heights', () => {
1495
1738
  };
1496
1739
 
1497
1740
  it('55. is 68px with neither axis on, whatever the leading', async () => {
1498
- for (const leading of ['none', 'back', 'app-icon'] as const) {
1741
+ for (const leading of LEADINGS) {
1499
1742
  const el = await header({ pageTitle: 'Applications', leading });
1500
1743
  expect(bar(el).getBoundingClientRect().height, leading).to.equal(68);
1501
1744
  // The leading axis changes what is in the bar, never how tall it is.
@@ -1542,8 +1785,8 @@ describe('NileAppShell — the four header heights', () => {
1542
1785
  });
1543
1786
 
1544
1787
  // The three axes are independent, so all twelve combinations land on one of the four heights.
1545
- it('58. resolves all twelve variants to exactly the four heights', async () => {
1546
- for (const leading of ['none', 'back', 'app-icon'] as const) {
1788
+ it('58. resolves every variant to exactly the four heights', async () => {
1789
+ for (const leading of LEADINGS) {
1547
1790
  for (const tabs of [false, true]) {
1548
1791
  for (const described of [false, true]) {
1549
1792
  /* eslint-disable-next-line no-nested-ternary */