@aquera/nile-elements 2.0.10 → 2.0.12
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.
- package/README.md +6 -0
- package/demo/app-shell.html +852 -0
- package/demo/nxtgen-classes.css +2 -2
- package/demo/nxtgen-properties.css +2 -2
- package/demo/nxtgen-utilities.css +2 -2
- package/demo/nxtgen.css +2 -2
- package/demo/utilities.css +2 -2
- package/demo/variables.css +2 -2
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.js +595 -302
- package/dist/internal/app-shell-utility-control-styles.cjs.js +2 -0
- package/dist/internal/app-shell-utility-control-styles.cjs.js.map +1 -0
- package/dist/internal/app-shell-utility-control-styles.esm.js +16 -0
- package/dist/nile-app-shell/index.cjs.js +1 -1
- package/dist/nile-app-shell/index.esm.js +1 -1
- package/dist/nile-app-shell/nile-app-shell.cjs.js +1 -1
- package/dist/nile-app-shell/nile-app-shell.cjs.js.map +1 -1
- package/dist/nile-app-shell/nile-app-shell.css.cjs.js +1 -1
- package/dist/nile-app-shell/nile-app-shell.css.cjs.js.map +1 -1
- package/dist/nile-app-shell/nile-app-shell.css.esm.js +277 -96
- package/dist/nile-app-shell/nile-app-shell.esm.js +67 -11
- package/dist/nile-app-shell/shell-controller.cjs.js.map +1 -1
- package/dist/nile-app-shell-main/nile-app-shell-main.cjs.js.map +1 -1
- package/dist/nile-app-shell-nav-toggle/index.cjs.js +1 -1
- package/dist/nile-app-shell-nav-toggle/index.esm.js +1 -1
- package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.cjs.js +1 -1
- package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.cjs.js.map +1 -1
- package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.cjs.js +1 -1
- package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.cjs.js.map +1 -1
- package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.esm.js +7 -3
- package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.esm.js +3 -3
- package/dist/nile-app-shell-panel/index.cjs.js +1 -1
- package/dist/nile-app-shell-panel/index.esm.js +1 -1
- package/dist/nile-app-shell-panel/nile-app-shell-panel.cjs.js +1 -1
- package/dist/nile-app-shell-panel/nile-app-shell-panel.cjs.js.map +1 -1
- package/dist/nile-app-shell-panel/nile-app-shell-panel.css.cjs.js +1 -1
- package/dist/nile-app-shell-panel/nile-app-shell-panel.css.cjs.js.map +1 -1
- package/dist/nile-app-shell-panel/nile-app-shell-panel.css.esm.js +32 -8
- package/dist/nile-app-shell-panel/nile-app-shell-panel.esm.js +18 -5
- package/dist/nile-app-shell-side-nav/nile-app-shell-side-nav.cjs.js.map +1 -1
- package/dist/nile-app-shell-side-nav/nile-app-shell-side-nav.css.cjs.js.map +1 -1
- package/dist/src/internal/app-shell-utility-control-styles.d.ts +12 -0
- package/dist/src/internal/app-shell-utility-control-styles.js +29 -0
- package/dist/src/internal/app-shell-utility-control-styles.js.map +1 -0
- package/dist/src/nile-app-shell/nile-app-shell-regions.test.d.ts +3 -0
- package/dist/src/nile-app-shell/nile-app-shell-regions.test.js +210 -0
- package/dist/src/nile-app-shell/nile-app-shell-regions.test.js.map +1 -1
- package/dist/src/nile-app-shell/nile-app-shell.css.d.ts +1 -9
- package/dist/src/nile-app-shell/nile-app-shell.css.js +277 -103
- package/dist/src/nile-app-shell/nile-app-shell.css.js.map +1 -1
- package/dist/src/nile-app-shell/nile-app-shell.d.ts +90 -122
- package/dist/src/nile-app-shell/nile-app-shell.js +235 -144
- package/dist/src/nile-app-shell/nile-app-shell.js.map +1 -1
- package/dist/src/nile-app-shell/nile-app-shell.test.d.ts +4 -0
- package/dist/src/nile-app-shell/nile-app-shell.test.js +1116 -29
- package/dist/src/nile-app-shell/nile-app-shell.test.js.map +1 -1
- package/dist/src/nile-app-shell/shell-controller.d.ts +1 -8
- package/dist/src/nile-app-shell/shell-controller.js +2 -14
- package/dist/src/nile-app-shell/shell-controller.js.map +1 -1
- package/dist/src/nile-app-shell-main/nile-app-shell-main.d.ts +2 -6
- package/dist/src/nile-app-shell-main/nile-app-shell-main.js +2 -6
- package/dist/src/nile-app-shell-main/nile-app-shell-main.js.map +1 -1
- package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.d.ts +1 -4
- package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.js +7 -5
- package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.js.map +1 -1
- package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.d.ts +4 -8
- package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.js +5 -6
- package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.js.map +1 -1
- package/dist/src/nile-app-shell-panel/nile-app-shell-panel.css.js +31 -6
- package/dist/src/nile-app-shell-panel/nile-app-shell-panel.css.js.map +1 -1
- package/dist/src/nile-app-shell-panel/nile-app-shell-panel.d.ts +18 -13
- package/dist/src/nile-app-shell-panel/nile-app-shell-panel.js +45 -16
- package/dist/src/nile-app-shell-panel/nile-app-shell-panel.js.map +1 -1
- package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.css.d.ts +1 -4
- package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.css.js +1 -4
- package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.css.js.map +1 -1
- package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.d.ts +4 -12
- package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.js +4 -12
- package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.js.map +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.cjs.js +1 -1
- package/dist/version.cjs.js.map +1 -1
- package/dist/version.esm.js +1 -1
- package/package.json +1 -1
- package/plop-templates/lit/lit.test.ts.hbs +22 -0
- package/plop-templates/lit/lit.ts.hbs +10 -5
- package/plopfile.js +12 -0
- package/src/internal/app-shell-utility-control-styles.ts +30 -0
- package/src/nile-app-shell/nile-app-shell-regions.test.ts +252 -0
- package/src/nile-app-shell/nile-app-shell.css.ts +277 -103
- package/src/nile-app-shell/nile-app-shell.test.ts +1363 -29
- package/src/nile-app-shell/nile-app-shell.ts +249 -149
- package/src/nile-app-shell/shell-controller.ts +2 -14
- package/src/nile-app-shell-main/nile-app-shell-main.ts +2 -6
- package/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.ts +7 -5
- package/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.ts +7 -11
- package/src/nile-app-shell-panel/nile-app-shell-panel.css.ts +31 -6
- package/src/nile-app-shell-panel/nile-app-shell-panel.ts +44 -16
- package/src/nile-app-shell-side-nav/nile-app-shell-side-nav.css.ts +1 -4
- package/src/nile-app-shell-side-nav/nile-app-shell-side-nav.ts +4 -12
- package/vscode-html-custom-data.json +85 -18
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
|
|
2
|
+
import { setViewport } from '@web/test-runner-commands';
|
|
2
3
|
import './nile-app-shell';
|
|
3
4
|
import '../nile-app-shell-nav-toggle/nile-app-shell-nav-toggle';
|
|
5
|
+
import '../nile-app-shell-panel/nile-app-shell-panel';
|
|
6
|
+
import '../nile-nav-tab-group/nile-nav-tab-group';
|
|
7
|
+
import '../nile-nav-tab/nile-nav-tab';
|
|
4
8
|
import '../nile-side-bar/nile-side-bar';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*/
|
|
9
|
+
// renderGlyph fails silently on an unknown name, so the glyph tests need the real element.
|
|
10
|
+
import '@aquera/nile-glyph/nile-glyph';
|
|
11
|
+
// Sizes the host explicitly and waits for the ResizeObserver to land.
|
|
9
12
|
const sized = async (width) => {
|
|
10
13
|
const el = await fixture(html `
|
|
11
14
|
<nile-app-shell style="width: ${width}px; height: 600px;">
|
|
@@ -34,11 +37,7 @@ const overlaying = async (width) => {
|
|
|
34
37
|
await waitUntil(() => el.hasAttribute('has-panel'));
|
|
35
38
|
return el;
|
|
36
39
|
};
|
|
37
|
-
|
|
38
|
-
* The side-nav track animates, so a width read straight after a state change
|
|
39
|
-
* catches the row mid-transition and sees room that is about to go away. Waits
|
|
40
|
-
* for two consecutive frames to agree before geometry is asserted.
|
|
41
|
-
*/
|
|
40
|
+
// Waits for two consecutive frames to agree before geometry is asserted.
|
|
42
41
|
const settled = async (el) => {
|
|
43
42
|
const section = el.shadowRoot.querySelector('.content-section');
|
|
44
43
|
let last = -1;
|
|
@@ -85,6 +84,17 @@ describe('NileAppShell — regions', () => {
|
|
|
85
84
|
expect(part(el, 'container').contains(bar)).to.be.false;
|
|
86
85
|
});
|
|
87
86
|
});
|
|
87
|
+
/**
|
|
88
|
+
* The name the control's glyph asks for, once nile-glyph has resolved it to
|
|
89
|
+
* real path data. An unknown name leaves an empty <svg>, so this fails loudly
|
|
90
|
+
* rather than letting a missing icon ship as a blank box.
|
|
91
|
+
*/
|
|
92
|
+
const drawnGlyph = async (control) => {
|
|
93
|
+
const glyph = control.querySelector('nile-glyph');
|
|
94
|
+
expect(glyph, 'the control renders a nile-glyph').to.exist;
|
|
95
|
+
await waitUntil(() => (glyph.shadowRoot?.querySelector('svg')?.childElementCount ?? 0) > 0, `${glyph.getAttribute('name')} draws no paths — is it in the nile-glyph set?`);
|
|
96
|
+
return glyph.getAttribute('name');
|
|
97
|
+
};
|
|
88
98
|
describe('NileAppShell — nav', () => {
|
|
89
99
|
it('6. collapseNav()/expandNav() drive the rail and reflect it', async () => {
|
|
90
100
|
const el = await sized(1440);
|
|
@@ -134,6 +144,13 @@ describe('NileAppShell — nav', () => {
|
|
|
134
144
|
expect(button.disabled).to.be.true;
|
|
135
145
|
expect(el.hasAttribute('rail')).to.be.true;
|
|
136
146
|
});
|
|
147
|
+
it('62. the built-in toggle draws a real glyph in both states', async () => {
|
|
148
|
+
const el = await sized(1440);
|
|
149
|
+
expect(await drawnGlyph(part(el, 'nav-toggle'))).to.equal('ng-panel-left');
|
|
150
|
+
el.collapseNav();
|
|
151
|
+
await el.updateComplete;
|
|
152
|
+
expect(await drawnGlyph(part(el, 'nav-toggle'))).to.equal('ng-panel-right');
|
|
153
|
+
});
|
|
137
154
|
});
|
|
138
155
|
describe('NileAppShell — Ask Aquera panel', () => {
|
|
139
156
|
it('11. openPanel()/closePanel() drive panel-open', async () => {
|
|
@@ -173,7 +190,7 @@ describe('NileAppShell — Ask Aquera panel', () => {
|
|
|
173
190
|
expect(el.getAttribute('panel-mode')).to.equal('docked');
|
|
174
191
|
const container = el.shadowRoot.querySelector('.container');
|
|
175
192
|
const panel = el.shadowRoot.querySelector('.panel');
|
|
176
|
-
//
|
|
193
|
+
// The page sits beside the panel, not under it.
|
|
177
194
|
expect(container.getBoundingClientRect().right).to.be.at.most(Math.round(panel.getBoundingClientRect().left) + 1);
|
|
178
195
|
}
|
|
179
196
|
});
|
|
@@ -208,6 +225,46 @@ describe('NileAppShell — Ask Aquera panel', () => {
|
|
|
208
225
|
expect(part(el, 'panel').getAttribute('aria-label')).to.equal('Ask Aquera');
|
|
209
226
|
expect(part(el, 'panel-title').textContent.trim()).to.equal('Ask Aquera');
|
|
210
227
|
});
|
|
228
|
+
// Amendment A (A-8): Figma's Assistant Title is Text lg/Semibold, 18/28; the shell sets it on the title it renders itself.
|
|
229
|
+
it('17b. sets its own panel title at 18/28 in text-primary-900', async () => {
|
|
230
|
+
const tokens = '--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)';
|
|
231
|
+
const el = await sized(1440);
|
|
232
|
+
el.setAttribute('style', `${el.getAttribute('style')}; ${tokens}`);
|
|
233
|
+
el.openPanel();
|
|
234
|
+
await el.updateComplete;
|
|
235
|
+
const own = getComputedStyle(part(el, 'panel-title'));
|
|
236
|
+
expect(own.fontSize).to.equal('18px');
|
|
237
|
+
expect(own.lineHeight).to.equal('28px');
|
|
238
|
+
expect(own.fontWeight).to.equal('600');
|
|
239
|
+
expect(own.color).to.equal('rgb(24, 29, 39)');
|
|
240
|
+
});
|
|
241
|
+
// A-8: a slotted nile-app-shell-panel (part of App Shell) draws the same Figma title: 18/28 SemiBold, text-primary-900.
|
|
242
|
+
it('17c. sets the nile-app-shell-panel title at 18/28 in text-primary-900', async () => {
|
|
243
|
+
const tokens = '--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)';
|
|
244
|
+
const slotted = await fixture(html `
|
|
245
|
+
<nile-app-shell-panel label="Ask Aquera" style="${tokens}"></nile-app-shell-panel>
|
|
246
|
+
`);
|
|
247
|
+
await slotted.updateComplete;
|
|
248
|
+
const style = getComputedStyle(slotted.shadowRoot.querySelector('.title'));
|
|
249
|
+
expect(style.fontSize).to.equal('18px');
|
|
250
|
+
expect(style.lineHeight).to.equal('28px');
|
|
251
|
+
expect(style.fontWeight).to.equal('600');
|
|
252
|
+
expect(style.color).to.equal('rgb(24, 29, 39)');
|
|
253
|
+
});
|
|
254
|
+
// The enterprise sheet keeps its own panel title: --nile-type-scale-3 and an inherited line height, exactly as before.
|
|
255
|
+
// Enterprise defines no --ng-* tokens, so the NxtGen line height does not apply there.
|
|
256
|
+
it('17d. leaves the enterprise nile-app-shell-panel title as it was', async () => {
|
|
257
|
+
const slotted = await fixture(html `
|
|
258
|
+
<nile-app-shell-panel
|
|
259
|
+
label="Ask Aquera"
|
|
260
|
+
style="--nile-type-scale-3: 14px; line-height: 20px"
|
|
261
|
+
></nile-app-shell-panel>
|
|
262
|
+
`);
|
|
263
|
+
await slotted.updateComplete;
|
|
264
|
+
const style = getComputedStyle(slotted.shadowRoot.querySelector('.title'));
|
|
265
|
+
expect(style.fontSize).to.equal('14px');
|
|
266
|
+
expect(style.lineHeight).to.equal('20px');
|
|
267
|
+
});
|
|
211
268
|
it('18. Cmd/Ctrl+J toggles the panel', async () => {
|
|
212
269
|
const el = await sized(1440);
|
|
213
270
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', ctrlKey: true }));
|
|
@@ -263,8 +320,7 @@ describe('NileAppShellNavToggle', () => {
|
|
|
263
320
|
`);
|
|
264
321
|
const toggle = el.querySelector('nile-app-shell-nav-toggle');
|
|
265
322
|
await waitUntil(() => toggle.appShell === el);
|
|
266
|
-
// The control is a nile-button
|
|
267
|
-
// deeper. The part is the contract either way.
|
|
323
|
+
// The control is a nile-button; the part is the contract either way.
|
|
268
324
|
const button = () => toggle.shadowRoot.querySelector('[part~="button"]');
|
|
269
325
|
button().click();
|
|
270
326
|
await el.updateComplete;
|
|
@@ -290,13 +346,27 @@ describe('NileAppShellNavToggle', () => {
|
|
|
290
346
|
await toggle.updateComplete;
|
|
291
347
|
expect(toggle.shadowRoot.querySelector('[part~="button"]').disabled).to.be.true;
|
|
292
348
|
});
|
|
349
|
+
it('63. draws a real glyph in both states', async () => {
|
|
350
|
+
const el = await fixture(html `
|
|
351
|
+
<nile-app-shell style="width: 1440px; height: 600px;" no-nav-toggle>
|
|
352
|
+
<nav slot="side-nav">nav</nav>
|
|
353
|
+
<nile-app-shell-nav-toggle slot="top-bar"></nile-app-shell-nav-toggle>
|
|
354
|
+
<p>page</p>
|
|
355
|
+
</nile-app-shell>
|
|
356
|
+
`);
|
|
357
|
+
const toggle = el.querySelector('nile-app-shell-nav-toggle');
|
|
358
|
+
await waitUntil(() => toggle.appShell === el);
|
|
359
|
+
const button = () => toggle.shadowRoot.querySelector('[part~="button"]');
|
|
360
|
+
expect(await drawnGlyph(button())).to.equal('ng-panel-left');
|
|
361
|
+
button().click();
|
|
362
|
+
await el.updateComplete;
|
|
363
|
+
await toggle.updateComplete;
|
|
364
|
+
expect(await drawnGlyph(button())).to.equal('ng-panel-right');
|
|
365
|
+
});
|
|
293
366
|
});
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
* not give is the row overflowing into :host's clip, which silently cuts the
|
|
298
|
-
* panel off the right edge.
|
|
299
|
-
*/
|
|
367
|
+
// The nav track is theme-dependent, so tests load one token pair or the other explicitly.
|
|
368
|
+
const ENTERPRISE_NAV = '--nile-width-240px: 240px; --nile-width-70px: 70px;';
|
|
369
|
+
const NXTGEN_NAV = '--ng-spacing-296: 296px; --ng-spacing-68: 68px;';
|
|
300
370
|
describe('NileAppShell — the content row never overflows', () => {
|
|
301
371
|
const withAside = async (width) => {
|
|
302
372
|
const el = await fixture(html `
|
|
@@ -317,8 +387,7 @@ describe('NileAppShell — the content row never overflows', () => {
|
|
|
317
387
|
return (section.scrollWidth - Math.round(section.getBoundingClientRect().width));
|
|
318
388
|
};
|
|
319
389
|
it('23. tightens the side columns rather than covering the page', async () => {
|
|
320
|
-
//
|
|
321
|
-
// some width back — and the page still sits beside the panel, not under.
|
|
390
|
+
// Short of what the row prefers, so the panel and aside give width back while the page stays beside the panel.
|
|
322
391
|
const el = await withAside(1500);
|
|
323
392
|
await waitUntil(() => el.navLocked);
|
|
324
393
|
await settled(el);
|
|
@@ -367,11 +436,77 @@ describe('NileAppShell — the content row never overflows', () => {
|
|
|
367
436
|
expect(container.getBoundingClientRect().width).to.be.at.least(640);
|
|
368
437
|
expect(overflow(el)).to.equal(0);
|
|
369
438
|
});
|
|
439
|
+
// Pins the stylesheet defaults to the JS geometry fallbacks so the two copies cannot drift apart.
|
|
440
|
+
it('71. the stylesheet defaults still match the JS geometry fallbacks', async () => {
|
|
441
|
+
const el = await fixture(html `
|
|
442
|
+
<nile-app-shell
|
|
443
|
+
style="width: 1440px; height: 600px; --nile-spacing-xs: 4px;"
|
|
444
|
+
>
|
|
445
|
+
<nav slot="side-nav">nav</nav>
|
|
446
|
+
<p>page</p>
|
|
447
|
+
</nile-app-shell>
|
|
448
|
+
`);
|
|
449
|
+
await el.updateComplete;
|
|
450
|
+
const value = (name) => parseFloat(getComputedStyle(el).getPropertyValue(name));
|
|
451
|
+
// No theme sheet here, so the nav track falls to its own enterprise-pair literals, which is what _geometry() carries too.
|
|
452
|
+
expect(value('--nile-app-shell-side-nav-width')).to.equal(240);
|
|
453
|
+
expect(value('--nile-app-shell-side-nav-rail-width')).to.equal(70);
|
|
454
|
+
expect(value('--nile-app-shell-gutter')).to.equal(4);
|
|
455
|
+
expect(value('--nile-app-shell-content-min-width')).to.equal(640);
|
|
456
|
+
expect(value('--nile-app-shell-aside-width')).to.equal(320);
|
|
457
|
+
expect(value('--nile-app-shell-panel-width')).to.equal(480);
|
|
458
|
+
});
|
|
459
|
+
// The nav track mirrors nile-side-bar's token expressions and resolves per theme.
|
|
460
|
+
it('78. resolves the nav track per theme, as nile-side-bar does', async () => {
|
|
461
|
+
const track = async (tokens) => {
|
|
462
|
+
const el = await fixture(html `
|
|
463
|
+
<nile-app-shell style="width: 1440px; height: 600px; ${tokens}">
|
|
464
|
+
<nav slot="side-nav">nav</nav>
|
|
465
|
+
<p>page</p>
|
|
466
|
+
</nile-app-shell>
|
|
467
|
+
`);
|
|
468
|
+
await el.updateComplete;
|
|
469
|
+
const read = (name) => parseFloat(getComputedStyle(el).getPropertyValue(name));
|
|
470
|
+
return [
|
|
471
|
+
read('--nile-app-shell-side-nav-width'),
|
|
472
|
+
read('--nile-app-shell-side-nav-rail-width'),
|
|
473
|
+
];
|
|
474
|
+
};
|
|
475
|
+
expect(await track('--nile-width-240px: 240px; --nile-width-70px: 70px;'), 'enterprise').to.deep.equal([240, 70]);
|
|
476
|
+
expect(await track('--ng-spacing-296: 296px; --ng-spacing-68: 68px;'), 'nxtgen').to.deep.equal([296, 68]);
|
|
477
|
+
});
|
|
478
|
+
// The nav rails at the row's own theme-dependent width requirement (enterprise 1368, nxtgen 1424), with docked-rail-breakpoint at 0 so the measured value governs.
|
|
479
|
+
it('72. rails the nav at the row’s own width requirement, not a gutter early', async () => {
|
|
480
|
+
const at = async (width, tokens = ENTERPRISE_NAV) => {
|
|
481
|
+
const el = await fixture(html `
|
|
482
|
+
<nile-app-shell
|
|
483
|
+
style="width: ${width}px; height: 600px; ${tokens}"
|
|
484
|
+
docked-rail-breakpoint="0"
|
|
485
|
+
>
|
|
486
|
+
<nav slot="side-nav">nav</nav>
|
|
487
|
+
<p>page</p>
|
|
488
|
+
<div slot="panel">assistant</div>
|
|
489
|
+
</nile-app-shell>
|
|
490
|
+
`);
|
|
491
|
+
await waitUntil(() => el.hasAttribute('has-panel'));
|
|
492
|
+
el.openPanel();
|
|
493
|
+
await el.updateComplete;
|
|
494
|
+
await settled(el);
|
|
495
|
+
return el.navLocked;
|
|
496
|
+
};
|
|
497
|
+
// Exactly the requirement: the row fits, so the nav keeps its width.
|
|
498
|
+
expect(await at(1368)).to.be.false;
|
|
499
|
+
// One pixel under: the track is what the row cannot afford.
|
|
500
|
+
expect(await at(1367)).to.be.true;
|
|
501
|
+
expect(await at(1370)).to.be.false;
|
|
502
|
+
// NxtGen's wider track moves the same boundary up by 56px, following whatever the side bar measures.
|
|
503
|
+
expect(await at(1424, NXTGEN_NAV)).to.be.false;
|
|
504
|
+
expect(await at(1423, NXTGEN_NAV)).to.be.true;
|
|
505
|
+
// And the enterprise boundary is comfortably inside NxtGen's requirement.
|
|
506
|
+
expect(await at(1368, NXTGEN_NAV)).to.be.true;
|
|
507
|
+
});
|
|
370
508
|
});
|
|
371
|
-
|
|
372
|
-
* An empty flex item is not a free one: it still takes the row's gap before
|
|
373
|
-
* it, which is enough to pull the bar's content short of its padding edge.
|
|
374
|
-
*/
|
|
509
|
+
// An empty flex item still takes the row's gap, pulling the bar's content short of its padding edge.
|
|
375
510
|
describe('NileAppShell — the top bar reaches its padding edge', () => {
|
|
376
511
|
it('28. an empty actions group reserves no gap', async () => {
|
|
377
512
|
const el = await sized(1440);
|
|
@@ -400,12 +535,43 @@ describe('NileAppShell — the top bar reaches its padding edge', () => {
|
|
|
400
535
|
expect(getComputedStyle(actions).display).to.not.equal('none');
|
|
401
536
|
expect(actions.getBoundingClientRect().width).to.be.greaterThan(0);
|
|
402
537
|
});
|
|
538
|
+
// The rule that separates the nav toggle from the header's content follows both a filled slot and a title block.
|
|
539
|
+
it('76. draws the toggle rule for a title block, not just a filled slot', async () => {
|
|
540
|
+
const rule = (el) => part(el, 'rule');
|
|
541
|
+
const slotted = await fixture(html `
|
|
542
|
+
<nile-app-shell style="width: 1440px; height: 600px;">
|
|
543
|
+
<nav slot="side-nav">nav</nav>
|
|
544
|
+
<span slot="top-bar">Title</span>
|
|
545
|
+
<p>page</p>
|
|
546
|
+
</nile-app-shell>
|
|
547
|
+
`);
|
|
548
|
+
await waitUntil(() => slotted.hasAttribute('has-top-bar'));
|
|
549
|
+
expect(rule(slotted)).to.exist;
|
|
550
|
+
// The same heading through pageTitle, top-bar slot empty.
|
|
551
|
+
const titled = await fixture(html `
|
|
552
|
+
<nile-app-shell
|
|
553
|
+
style="width: 1440px; height: 600px;"
|
|
554
|
+
pageTitle="Applications"
|
|
555
|
+
>
|
|
556
|
+
<nav slot="side-nav">nav</nav>
|
|
557
|
+
<p>page</p>
|
|
558
|
+
</nile-app-shell>
|
|
559
|
+
`);
|
|
560
|
+
await titled.updateComplete;
|
|
561
|
+
expect(titled.hasAttribute('has-top-bar')).to.be.true;
|
|
562
|
+
expect(rule(titled), 'a title block earns the rule too').to.exist;
|
|
563
|
+
// Neither: the toggle stands alone, so there is nothing to separate.
|
|
564
|
+
const bare = await fixture(html `
|
|
565
|
+
<nile-app-shell style="width: 1440px; height: 600px;">
|
|
566
|
+
<nav slot="side-nav">nav</nav>
|
|
567
|
+
<p>page</p>
|
|
568
|
+
</nile-app-shell>
|
|
569
|
+
`);
|
|
570
|
+
await bare.updateComplete;
|
|
571
|
+
expect(rule(bare)).to.be.null;
|
|
572
|
+
});
|
|
403
573
|
});
|
|
404
|
-
|
|
405
|
-
* The Ask door moved onto the shell when nile-app-shell-header became a slot.
|
|
406
|
-
* It is the one control in the bar that cannot be slotted markup: it turns on
|
|
407
|
-
* whether a panel is mounted for this route, and it calls openPanel().
|
|
408
|
-
*/
|
|
574
|
+
// The built-in Ask button turns on whether a panel is mounted for this route and calls openPanel().
|
|
409
575
|
describe('NileAppShell — the built-in Ask button', () => {
|
|
410
576
|
const ask = (el) => part(el, 'ask');
|
|
411
577
|
it('30. opens the panel', async () => {
|
|
@@ -450,5 +616,926 @@ describe('NileAppShell — the built-in Ask button', () => {
|
|
|
450
616
|
expect(getComputedStyle(actions).display).to.not.equal('none');
|
|
451
617
|
expect(actions.getBoundingClientRect().width).to.be.greaterThan(0);
|
|
452
618
|
});
|
|
619
|
+
// Amendment A (M8): the design's Ask is the secondary button, not the filled primary.
|
|
620
|
+
it('34b. renders Ask as the secondary variant', async () => {
|
|
621
|
+
const el = await sized(1440);
|
|
622
|
+
expect(ask(el).getAttribute('variant')).to.equal('secondary');
|
|
623
|
+
});
|
|
624
|
+
// Amendment A (M9, M10): app actions, a 16px divider, then Ask, 16px apart; with no app actions, no divider.
|
|
625
|
+
it('34c. draws a divider before Ask only when the page slots actions, 16px from both', async () => {
|
|
626
|
+
const alone = await sized(1440);
|
|
627
|
+
await settled(alone);
|
|
628
|
+
expect(part(alone, 'ask-rule')).to.be.null;
|
|
629
|
+
const el = await fixture(html `
|
|
630
|
+
<nile-app-shell style="width: 1440px; height: 600px; --ng-spacing-xl: 16px;">
|
|
631
|
+
<nav slot="side-nav">nav</nav>
|
|
632
|
+
<span slot="top-bar">Title</span>
|
|
633
|
+
<button slot="top-bar-actions" id="one">One</button>
|
|
634
|
+
<button slot="top-bar-actions" id="two">Two</button>
|
|
635
|
+
<p>page</p>
|
|
636
|
+
<div slot="panel">assistant</div>
|
|
637
|
+
</nile-app-shell>
|
|
638
|
+
`);
|
|
639
|
+
await waitUntil(() => el.hasAttribute('has-top-bar-actions'));
|
|
640
|
+
await el.updateComplete;
|
|
641
|
+
const rule = part(el, 'ask-rule');
|
|
642
|
+
expect(rule).to.exist;
|
|
643
|
+
const one = el.querySelector('#one').getBoundingClientRect();
|
|
644
|
+
const two = el.querySelector('#two').getBoundingClientRect();
|
|
645
|
+
const ruleBox = rule.getBoundingClientRect();
|
|
646
|
+
const askBox = ask(el).getBoundingClientRect();
|
|
647
|
+
expect(Math.round(ruleBox.height)).to.equal(16);
|
|
648
|
+
expect(Math.round(two.left - one.right)).to.equal(16);
|
|
649
|
+
expect(Math.round(ruleBox.left - two.right)).to.equal(16);
|
|
650
|
+
expect(Math.round(askBox.left - ruleBox.left)).to.equal(16);
|
|
651
|
+
// Order: the page's actions, the divider, then Ask.
|
|
652
|
+
/* eslint-disable-next-line no-bitwise */
|
|
653
|
+
const ruleBeforeAsk = rule.compareDocumentPosition(ask(el)) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
654
|
+
expect(ruleBeforeAsk).to.be.greaterThan(0);
|
|
655
|
+
el.noAsk = true;
|
|
656
|
+
await el.updateComplete;
|
|
657
|
+
expect(part(el, 'ask-rule'), 'no Ask, no divider').to.be.null;
|
|
658
|
+
});
|
|
659
|
+
// The shell draws its icon controls as bare glyphs via ::part(base), overriding nile-button's circle and ghost surface.
|
|
660
|
+
const utilityBase = (el, name) => part(el, name).shadowRoot.querySelector('[part~="base"]');
|
|
661
|
+
it('35. draws its icon controls with no resting surface', async () => {
|
|
662
|
+
const el = await sized(1440);
|
|
663
|
+
el.openPanel();
|
|
664
|
+
await el.updateComplete;
|
|
665
|
+
for (const name of ['nav-toggle', 'panel-close']) {
|
|
666
|
+
const base = utilityBase(el, name);
|
|
667
|
+
expect(getComputedStyle(base).backgroundColor).to.equal('rgba(0, 0, 0, 0)');
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
it('36. sizes its icon controls to the utility box, not the button circle', async () => {
|
|
671
|
+
const el = await sized(1440);
|
|
672
|
+
el.openPanel();
|
|
673
|
+
await el.updateComplete;
|
|
674
|
+
for (const name of ['nav-toggle', 'panel-close']) {
|
|
675
|
+
const box = utilityBase(el, name).getBoundingClientRect();
|
|
676
|
+
expect(box.width).to.equal(32);
|
|
677
|
+
expect(box.height).to.equal(32);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
it('37. holds the panel back control until panelBack asks for it', async () => {
|
|
681
|
+
const el = await sized(1440);
|
|
682
|
+
el.openPanel();
|
|
683
|
+
await el.updateComplete;
|
|
684
|
+
const mark = el.shadowRoot.querySelector('.panel-mark');
|
|
685
|
+
// Not rendered at all in the root view, so no stylesheet override can surface it.
|
|
686
|
+
expect(part(el, 'panel-back')).to.not.exist;
|
|
687
|
+
expect(getComputedStyle(mark).display).to.not.equal('none');
|
|
688
|
+
el.panelBack = true;
|
|
689
|
+
await el.updateComplete;
|
|
690
|
+
const back = part(el, 'panel-back');
|
|
691
|
+
expect(back).to.exist;
|
|
692
|
+
expect(getComputedStyle(back).display).to.not.equal('none');
|
|
693
|
+
// The back control stands where the brand mark stands in the root view.
|
|
694
|
+
expect(getComputedStyle(mark).display).to.equal('none');
|
|
695
|
+
});
|
|
696
|
+
it('38. emits nile-panel-back and takes its label from panelBackLabel', async () => {
|
|
697
|
+
const el = await sized(1440);
|
|
698
|
+
el.panelBack = true;
|
|
699
|
+
el.panelBackLabel = 'Back to chats';
|
|
700
|
+
el.openPanel();
|
|
701
|
+
await el.updateComplete;
|
|
702
|
+
const back = part(el, 'panel-back');
|
|
703
|
+
expect(back.getAttribute('aria-label')).to.equal('Back to chats');
|
|
704
|
+
const went = oneEvent(el, 'nile-panel-back');
|
|
705
|
+
back.click();
|
|
706
|
+
await went;
|
|
707
|
+
// The shell keeps no view stack, so going back leaves the panel open.
|
|
708
|
+
expect(el.panelOpen).to.be.true;
|
|
709
|
+
});
|
|
710
|
+
it('81. goBackPanel() emits nile-panel-back, and goBack() stays its alias', async () => {
|
|
711
|
+
const el = await sized(1440);
|
|
712
|
+
const named = oneEvent(el, 'nile-panel-back');
|
|
713
|
+
el.goBackPanel();
|
|
714
|
+
expect(await named).to.exist;
|
|
715
|
+
// Deprecated, but still the same event: existing callers keep working.
|
|
716
|
+
const aliased = oneEvent(el, 'nile-panel-back');
|
|
717
|
+
el.goBack();
|
|
718
|
+
expect(await aliased).to.exist;
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
describe('NileAppShell — the tab strip', () => {
|
|
722
|
+
/** The strip is slot-driven, so it needs something in the slot to exist. */
|
|
723
|
+
const tabbed = async () => {
|
|
724
|
+
const el = await fixture(html `
|
|
725
|
+
<nile-app-shell style="width: 1440px; height: 600px; ${HEADER_TOKENS}">
|
|
726
|
+
<nav slot="side-nav">nav</nav>
|
|
727
|
+
<span slot="top-bar">Title</span>
|
|
728
|
+
<nile-nav-tab-group slot="tabs">
|
|
729
|
+
<nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
|
|
730
|
+
<nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
|
|
731
|
+
</nile-nav-tab-group>
|
|
732
|
+
<p>page</p>
|
|
733
|
+
</nile-app-shell>
|
|
734
|
+
`);
|
|
735
|
+
await waitUntil(() => el.hasAttribute('has-tabs'));
|
|
736
|
+
return el;
|
|
737
|
+
};
|
|
738
|
+
const strip = (el) => el.shadowRoot.querySelector('.tabs');
|
|
739
|
+
it('39. mounts the strip for a filled slot', async () => {
|
|
740
|
+
const el = await tabbed();
|
|
741
|
+
expect(el.tabsVisible).to.be.true;
|
|
742
|
+
expect(getComputedStyle(strip(el)).display).to.not.equal('none');
|
|
743
|
+
});
|
|
744
|
+
it('40. noTabs hides the strip without emptying the slot', async () => {
|
|
745
|
+
const el = await tabbed();
|
|
746
|
+
el.noTabs = true;
|
|
747
|
+
await el.updateComplete;
|
|
748
|
+
expect(el.tabsVisible).to.be.false;
|
|
749
|
+
expect(getComputedStyle(strip(el)).display).to.equal('none');
|
|
750
|
+
// The slot is still filled — the strip is switched off, not unmounted — so the assigned element proves the content stays.
|
|
751
|
+
expect(el.querySelector('nile-nav-tab-group')).to.exist;
|
|
752
|
+
const slot = strip(el).querySelector('slot');
|
|
753
|
+
expect(slot.assignedElements()).to.have.lengthOf(1);
|
|
754
|
+
el.noTabs = false;
|
|
755
|
+
await el.updateComplete;
|
|
756
|
+
expect(el.tabsVisible).to.be.true;
|
|
757
|
+
expect(getComputedStyle(strip(el)).display).to.not.equal('none');
|
|
758
|
+
});
|
|
759
|
+
// has-tabs advertises the visible strip, not the filled slot, matching has-panel and has-ask.
|
|
760
|
+
it('68. has-tabs follows the visible strip, not the filled slot', async () => {
|
|
761
|
+
// Slotted, not switched off: the strip is up and the flag is on.
|
|
762
|
+
const el = await tabbed();
|
|
763
|
+
expect(el.tabsVisible).to.be.true;
|
|
764
|
+
expect(el.hasAttribute('has-tabs')).to.be.true;
|
|
765
|
+
// Slotted, switched off: the slot stays filled, the flag goes with the strip.
|
|
766
|
+
el.noTabs = true;
|
|
767
|
+
await el.updateComplete;
|
|
768
|
+
expect(el.querySelector('nile-nav-tab-group')).to.exist;
|
|
769
|
+
expect(el.hasAttribute('has-tabs')).to.be.false;
|
|
770
|
+
expect(getComputedStyle(strip(el)).display).to.equal('none');
|
|
771
|
+
// And back, so the flag is not a one-way latch.
|
|
772
|
+
el.noTabs = false;
|
|
773
|
+
await el.updateComplete;
|
|
774
|
+
expect(el.hasAttribute('has-tabs')).to.be.true;
|
|
775
|
+
// Nothing slotted: no strip, no flag, whatever noTabs says.
|
|
776
|
+
const bare = await sized(1440);
|
|
777
|
+
await settled(bare);
|
|
778
|
+
expect(bare.hasAttribute('has-tabs')).to.be.false;
|
|
779
|
+
bare.noTabs = true;
|
|
780
|
+
await bare.updateComplete;
|
|
781
|
+
expect(bare.hasAttribute('has-tabs')).to.be.false;
|
|
782
|
+
});
|
|
783
|
+
it('41. leaves the strip off when the slot is empty', async () => {
|
|
784
|
+
const el = await sized(1440);
|
|
785
|
+
await settled(el);
|
|
786
|
+
expect(el.hasAttribute('has-tabs')).to.be.false;
|
|
787
|
+
expect(el.tabsVisible).to.be.false;
|
|
788
|
+
});
|
|
789
|
+
it('77. sizes bare slotted tab controls to their labels, not the strip', async () => {
|
|
790
|
+
const el = await fixture(html `
|
|
791
|
+
<nile-app-shell style="width: 1176px; height: 600px; ${HEADER_TOKENS}">
|
|
792
|
+
<nav slot="side-nav">nav</nav>
|
|
793
|
+
<span slot="top-bar">Title</span>
|
|
794
|
+
<button slot="tabs" id="short">A</button>
|
|
795
|
+
<button slot="tabs" id="long">A considerably longer tab label</button>
|
|
796
|
+
<p>page</p>
|
|
797
|
+
</nile-app-shell>
|
|
798
|
+
`);
|
|
799
|
+
await waitUntil(() => el.hasAttribute('has-tabs'));
|
|
800
|
+
await settled(el);
|
|
801
|
+
const short = el.querySelector('#short').getBoundingClientRect().width;
|
|
802
|
+
const long = el.querySelector('#long').getBoundingClientRect().width;
|
|
803
|
+
// Stretched, they would be equal; sized to content they are not close.
|
|
804
|
+
expect(long).to.be.greaterThan(short * 2);
|
|
805
|
+
});
|
|
806
|
+
it('43. leaves the slotted strip one rule, edge to edge', async () => {
|
|
807
|
+
const el = await tabbed();
|
|
808
|
+
const group = el.querySelector('nile-nav-tab-group');
|
|
809
|
+
const groupStyle = getComputedStyle(group);
|
|
810
|
+
expect(groupStyle.getPropertyValue('--track-width').trim()).to.equal('0px');
|
|
811
|
+
expect(groupStyle.alignSelf).to.equal('flex-end');
|
|
812
|
+
// The region's rule is as wide as the container it closes, padding and all.
|
|
813
|
+
const container = el.shadowRoot.querySelector('.container');
|
|
814
|
+
const stripWidth = strip(el).getBoundingClientRect().width;
|
|
815
|
+
const inner = container.getBoundingClientRect().width -
|
|
816
|
+
parseFloat(getComputedStyle(container).borderLeftWidth) -
|
|
817
|
+
parseFloat(getComputedStyle(container).borderRightWidth);
|
|
818
|
+
expect(Math.round(stripWidth)).to.equal(Math.round(inner));
|
|
819
|
+
// The group is wholly inside the region, and ends where the rule is.
|
|
820
|
+
const strippedBottom = strip(el).getBoundingClientRect().bottom;
|
|
821
|
+
const groupBottom = group.getBoundingClientRect().bottom;
|
|
822
|
+
expect(strippedBottom - groupBottom).to.be.lessThan(2);
|
|
823
|
+
});
|
|
824
|
+
// The Header Bar draws no rule of its own; the one divider belongs to the tab rail, so switching the strip off leaves the header with no rule.
|
|
825
|
+
it('42. draws the header rule on the strip only, never on the bar', async () => {
|
|
826
|
+
const el = await tabbed();
|
|
827
|
+
const bar = el.shadowRoot.querySelector('.container > .top-bar');
|
|
828
|
+
expect(parseFloat(getComputedStyle(bar).borderBottomWidth)).to.equal(0);
|
|
829
|
+
expect(parseFloat(getComputedStyle(strip(el)).borderBottomWidth)).to.be.greaterThan(0);
|
|
830
|
+
expect(getComputedStyle(strip(el)).borderBottomColor).to.not.equal('rgba(0, 0, 0, 0)');
|
|
831
|
+
// No tabs, no rule anywhere in the header block.
|
|
832
|
+
el.noTabs = true;
|
|
833
|
+
await el.updateComplete;
|
|
834
|
+
expect(parseFloat(getComputedStyle(bar).borderBottomWidth)).to.equal(0);
|
|
835
|
+
});
|
|
836
|
+
// The bar and the strip sit flush, with the stroke as the separation, not space.
|
|
837
|
+
it('62. leaves no vertical gap between the bar and the strip', async () => {
|
|
838
|
+
const el = await tabbed();
|
|
839
|
+
const bar = el.shadowRoot.querySelector('.container > .top-bar');
|
|
840
|
+
const gap = strip(el).getBoundingClientRect().top -
|
|
841
|
+
bar.getBoundingClientRect().bottom;
|
|
842
|
+
expect(Math.abs(gap)).to.be.lessThan(0.5);
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
// The test page loads no theme sheet, so the header fixtures carry the nxtgen token values the title block and tab row read.
|
|
846
|
+
const HEADER_TOKENS = [
|
|
847
|
+
'--ng-spacing-none: 0px',
|
|
848
|
+
'--ng-border-width-1: 1px',
|
|
849
|
+
'--ng-colors-border-secondary: #E9EAEB',
|
|
850
|
+
'--ng-spacing-xxs: 2px',
|
|
851
|
+
'--ng-spacing-xs: 4px',
|
|
852
|
+
'--ng-spacing-md: 8px',
|
|
853
|
+
'--ng-spacing-md-alt: 10px',
|
|
854
|
+
'--ng-spacing-lg: 12px',
|
|
855
|
+
'--ng-spacing-xl: 16px',
|
|
856
|
+
'--ng-spacing-3xl: 24px',
|
|
857
|
+
'--ng-font-size-text-xs: 12px',
|
|
858
|
+
'--ng-line-height-text-xs: 18px',
|
|
859
|
+
'--ng-font-size-text-sm: 14px',
|
|
860
|
+
'--ng-line-height-text-sm: 20px',
|
|
861
|
+
'--ng-font-size-text-md: 16px',
|
|
862
|
+
'--ng-line-height-text-md: 24px',
|
|
863
|
+
].join('; ');
|
|
864
|
+
const LEADINGS = ['none', 'back', 'app-icon', 'back+app-icon'];
|
|
865
|
+
const hasBack = (leading) => leading === 'back' || leading === 'back+app-icon';
|
|
866
|
+
const hasIcon = (leading) => leading === 'app-icon' || leading === 'back+app-icon';
|
|
867
|
+
// A shell carrying whatever header axes a test asks for; an unset axis is the empty string, meaning "absent".
|
|
868
|
+
const header = async (axes = {}) => {
|
|
869
|
+
const el = await fixture(html `
|
|
870
|
+
<nile-app-shell
|
|
871
|
+
style="width: 1176px; height: 800px; ${HEADER_TOKENS}"
|
|
872
|
+
.pageTitle=${axes.pageTitle ?? ''}
|
|
873
|
+
.description=${axes.description ?? ''}
|
|
874
|
+
.badge=${axes.badge ?? ''}
|
|
875
|
+
.headerBack=${hasBack(axes.leading)}
|
|
876
|
+
.headerBackLabel=${axes.headerBackLabel ?? 'Back'}
|
|
877
|
+
>
|
|
878
|
+
<nav slot="side-nav">nav</nav>
|
|
879
|
+
${hasIcon(axes.leading)
|
|
880
|
+
? html `<span slot="app-icon" id="mark">A</span>`
|
|
881
|
+
: ''}
|
|
882
|
+
${axes.actions
|
|
883
|
+
? html `<button slot="top-bar-actions" id="cta">Button CTA</button>`
|
|
884
|
+
: ''}
|
|
885
|
+
${axes.tabs
|
|
886
|
+
? html `<nile-nav-tab-group slot="tabs">
|
|
887
|
+
<nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
|
|
888
|
+
<nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
|
|
889
|
+
</nile-nav-tab-group>`
|
|
890
|
+
: ''}
|
|
891
|
+
<p>page</p>
|
|
892
|
+
</nile-app-shell>
|
|
893
|
+
`);
|
|
894
|
+
if (axes.tabs)
|
|
895
|
+
await waitUntil(() => el.hasAttribute('has-tabs'));
|
|
896
|
+
if (hasIcon(axes.leading))
|
|
897
|
+
await waitUntil(() => el.hasAttribute('has-app-icon'));
|
|
898
|
+
if (axes.actions)
|
|
899
|
+
await waitUntil(() => el.hasAttribute('has-top-bar-actions'));
|
|
900
|
+
await el.updateComplete;
|
|
901
|
+
return el;
|
|
902
|
+
};
|
|
903
|
+
// The header title block: the shell's own heading, the Leading axis, the badge and the description line.
|
|
904
|
+
describe('NileAppShell — the header title block', () => {
|
|
905
|
+
it('44. draws no title block at all until an axis asks for one', async () => {
|
|
906
|
+
const el = await header();
|
|
907
|
+
expect(el.headerBack).to.be.false;
|
|
908
|
+
expect(el.hasAttribute('has-app-icon')).to.be.false;
|
|
909
|
+
expect(part(el, 'title-area')).to.be.null;
|
|
910
|
+
expect(part(el, 'page-title')).to.be.null;
|
|
911
|
+
expect(part(el, 'description')).to.be.null;
|
|
912
|
+
expect(part(el, 'badge')).to.be.null;
|
|
913
|
+
expect(el.hasAttribute('has-description')).to.be.false;
|
|
914
|
+
});
|
|
915
|
+
// description and badge are modifiers on the page title, so neither mounts the title block by itself.
|
|
916
|
+
it('73. does not mount a title block for a description or badge alone', async () => {
|
|
917
|
+
for (const axes of [
|
|
918
|
+
{ description: 'Manage the apps connected to this tenant' },
|
|
919
|
+
{ badge: '94 apps' },
|
|
920
|
+
{ description: 'Manage the apps', badge: '94 apps' },
|
|
921
|
+
]) {
|
|
922
|
+
const el = await header(axes);
|
|
923
|
+
const label = JSON.stringify(axes);
|
|
924
|
+
expect(part(el, 'title-area'), label).to.be.null;
|
|
925
|
+
expect(part(el, 'description'), label).to.be.null;
|
|
926
|
+
expect(part(el, 'badge'), label).to.be.null;
|
|
927
|
+
// And no height is reserved for the line that cannot render.
|
|
928
|
+
expect(el.hasAttribute('has-description'), label).to.be.false;
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
it('74. brings both back as soon as there is a title to hang them on', async () => {
|
|
932
|
+
const el = await header({
|
|
933
|
+
description: 'Manage the apps connected to this tenant',
|
|
934
|
+
badge: '94 apps',
|
|
935
|
+
});
|
|
936
|
+
expect(part(el, 'title-area')).to.be.null;
|
|
937
|
+
el.pageTitle = 'Applications';
|
|
938
|
+
await el.updateComplete;
|
|
939
|
+
expect(part(el, 'title-area')).to.exist;
|
|
940
|
+
expect(part(el, 'description')).to.exist;
|
|
941
|
+
expect(part(el, 'badge')).to.exist;
|
|
942
|
+
expect(el.hasAttribute('has-description')).to.be.true;
|
|
943
|
+
});
|
|
944
|
+
// The back arrow is the exception: it stands on its own but brings no empty title stack.
|
|
945
|
+
it('75. mounts the back arrow on its own, without an empty title stack', async () => {
|
|
946
|
+
const el = await header({ leading: 'back' });
|
|
947
|
+
expect(part(el, 'title-area')).to.exist;
|
|
948
|
+
expect(part(el, 'back')).to.exist;
|
|
949
|
+
expect(part(el, 'page-title')).to.be.null;
|
|
950
|
+
expect(el.shadowRoot.querySelector('.title-stack')).to.be.null;
|
|
951
|
+
expect(el.shadowRoot.querySelector('.title-row')).to.be.null;
|
|
952
|
+
});
|
|
953
|
+
it('45. renders pageTitle as the header’s own heading', async () => {
|
|
954
|
+
const el = await header({ pageTitle: 'Applications' });
|
|
955
|
+
const title = part(el, 'page-title');
|
|
956
|
+
expect(title).to.exist;
|
|
957
|
+
// A heading by role, not a hard-coded h1, so the page keeps control of its outline; level 1 by default.
|
|
958
|
+
expect(title.getAttribute('role')).to.equal('heading');
|
|
959
|
+
expect(el.headingLevel).to.equal(1);
|
|
960
|
+
expect(title.getAttribute('aria-level')).to.equal('1');
|
|
961
|
+
expect(title.textContent.trim()).to.equal('Applications');
|
|
962
|
+
expect(part(el, 'title-area')).to.exist;
|
|
963
|
+
});
|
|
964
|
+
// Birat's a11y review: a page with its own h1 needs the shell title one level down.
|
|
965
|
+
it('79. takes its heading level from the headingLevel property and attribute', async () => {
|
|
966
|
+
const el = await header({ pageTitle: 'Applications' });
|
|
967
|
+
el.headingLevel = 2;
|
|
968
|
+
await el.updateComplete;
|
|
969
|
+
const title = part(el, 'page-title');
|
|
970
|
+
expect(title.getAttribute('aria-level')).to.equal('2');
|
|
971
|
+
expect(el.getAttribute('headinglevel')).to.equal('2');
|
|
972
|
+
expect(title.textContent.trim()).to.equal('Applications');
|
|
973
|
+
const marked = await fixture(html `
|
|
974
|
+
<nile-app-shell pageTitle="Applications" headingLevel="3">
|
|
975
|
+
<p>page</p>
|
|
976
|
+
</nile-app-shell>
|
|
977
|
+
`);
|
|
978
|
+
await marked.updateComplete;
|
|
979
|
+
expect(marked.headingLevel).to.equal(3);
|
|
980
|
+
expect(part(marked, 'page-title').getAttribute('aria-level')).to.equal('3');
|
|
981
|
+
});
|
|
982
|
+
it('80. clamps an invalid heading level to a safe aria-level', async () => {
|
|
983
|
+
const el = await header({ pageTitle: 'Applications' });
|
|
984
|
+
const cases = [
|
|
985
|
+
[0, '1'],
|
|
986
|
+
[-3, '1'],
|
|
987
|
+
[7, '6'],
|
|
988
|
+
[2.7, '2'],
|
|
989
|
+
[NaN, '1'],
|
|
990
|
+
['abc', '1'],
|
|
991
|
+
[undefined, '1'],
|
|
992
|
+
];
|
|
993
|
+
for (const [value, expected] of cases) {
|
|
994
|
+
el.headingLevel = value;
|
|
995
|
+
await el.updateComplete;
|
|
996
|
+
expect(part(el, 'page-title').getAttribute('aria-level'), String(value)).to.equal(expected);
|
|
997
|
+
}
|
|
998
|
+
// From markup too: a non-numeric attribute still leaves a level-1 heading.
|
|
999
|
+
const marked = await fixture(html `
|
|
1000
|
+
<nile-app-shell pageTitle="Applications" headingLevel="nope">
|
|
1001
|
+
<p>page</p>
|
|
1002
|
+
</nile-app-shell>
|
|
1003
|
+
`);
|
|
1004
|
+
await marked.updateComplete;
|
|
1005
|
+
expect(part(marked, 'page-title').getAttribute('aria-level')).to.equal('1');
|
|
1006
|
+
expect(part(marked, 'page-title').textContent.trim()).to.equal('Applications');
|
|
1007
|
+
});
|
|
1008
|
+
// The title block renders before the top-bar slot, so a page slotting its own title keeps its output.
|
|
1009
|
+
it('46. renders the block before whatever the page slots into top-bar', async () => {
|
|
1010
|
+
const el = await fixture(html `
|
|
1011
|
+
<nile-app-shell style="width: 1176px; height: 800px;" pageTitle="Apps">
|
|
1012
|
+
<nav slot="side-nav">nav</nav>
|
|
1013
|
+
<span slot="top-bar" id="own">the page’s own bar</span>
|
|
1014
|
+
<p>page</p>
|
|
1015
|
+
</nile-app-shell>
|
|
1016
|
+
`);
|
|
1017
|
+
await el.updateComplete;
|
|
1018
|
+
const area = part(el, 'title-area');
|
|
1019
|
+
const slot = el.shadowRoot.querySelector('slot[name="top-bar"]');
|
|
1020
|
+
expect(el.querySelector('#own').assignedSlot).to.equal(slot);
|
|
1021
|
+
/* eslint-disable-next-line no-bitwise */
|
|
1022
|
+
const areaIsFirst = area.compareDocumentPosition(slot) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
1023
|
+
expect(areaIsFirst).to.be.greaterThan(0);
|
|
1024
|
+
});
|
|
1025
|
+
it('47. mounts the top bar for a title block on its own', async () => {
|
|
1026
|
+
const el = await fixture(html `
|
|
1027
|
+
<nile-app-shell
|
|
1028
|
+
style="width: 1176px; height: 800px;"
|
|
1029
|
+
pageTitle="Applications"
|
|
1030
|
+
no-nav-toggle
|
|
1031
|
+
>
|
|
1032
|
+
<p>page</p>
|
|
1033
|
+
</nile-app-shell>
|
|
1034
|
+
`);
|
|
1035
|
+
await el.updateComplete;
|
|
1036
|
+
// Nothing is slotted anywhere, and there is no nav toggle to mount it.
|
|
1037
|
+
expect(el.hasAttribute('has-side-nav')).to.be.false;
|
|
1038
|
+
expect(el.hasAttribute('has-top-bar')).to.be.true;
|
|
1039
|
+
expect(part(el, 'page-title')).to.exist;
|
|
1040
|
+
});
|
|
1041
|
+
it('48. renders a badge after the title, and only when set', async () => {
|
|
1042
|
+
const plain = await header({ pageTitle: 'Applications' });
|
|
1043
|
+
expect(part(plain, 'badge')).to.be.null;
|
|
1044
|
+
const el = await header({ pageTitle: 'Applications', badge: '12 active' });
|
|
1045
|
+
const badge = part(el, 'badge');
|
|
1046
|
+
expect(badge).to.exist;
|
|
1047
|
+
expect(badge.textContent.trim()).to.equal('12 active');
|
|
1048
|
+
/* eslint-disable-next-line no-bitwise */
|
|
1049
|
+
const titleIsFirst = part(el, 'page-title').compareDocumentPosition(badge) &
|
|
1050
|
+
Node.DOCUMENT_POSITION_FOLLOWING;
|
|
1051
|
+
expect(titleIsFirst).to.be.greaterThan(0);
|
|
1052
|
+
});
|
|
1053
|
+
// The badge's surface tokens must resolve on both the enterprise and nxtgen sheets, so each test pins one sheet on its own.
|
|
1054
|
+
const badged = (tokens) => fixture(html `
|
|
1055
|
+
<nile-app-shell
|
|
1056
|
+
style="width: 1176px; height: 800px; ${tokens}"
|
|
1057
|
+
pageTitle="Applications"
|
|
1058
|
+
badge="Beta"
|
|
1059
|
+
>
|
|
1060
|
+
<nav slot="side-nav">nav</nav>
|
|
1061
|
+
<p>page</p>
|
|
1062
|
+
</nile-app-shell>
|
|
1063
|
+
`);
|
|
1064
|
+
it('69. resolves the badge on the enterprise sheet alone', async () => {
|
|
1065
|
+
const el = await badged([
|
|
1066
|
+
'--nile-border-width-1: 1px',
|
|
1067
|
+
'--nile-colors-neutral-100: #FAFAFA',
|
|
1068
|
+
'--nile-colors-neutral-400: #E6E9EB',
|
|
1069
|
+
'--nile-colors-dark-500: #636363',
|
|
1070
|
+
// The ink the badge must not fall back to by inheriting the title's.
|
|
1071
|
+
'--nile-colors-dark-900: #000000',
|
|
1072
|
+
].join('; '));
|
|
1073
|
+
const style = getComputedStyle(part(el, 'badge'));
|
|
1074
|
+
expect(style.backgroundColor).to.equal('rgb(250, 250, 250)');
|
|
1075
|
+
expect(style.borderTopColor).to.equal('rgb(230, 233, 235)');
|
|
1076
|
+
expect(style.borderTopWidth).to.equal('1px');
|
|
1077
|
+
expect(style.color).to.equal('rgb(99, 99, 99)');
|
|
1078
|
+
});
|
|
1079
|
+
it('70. resolves the badge on the nxtgen sheet alone', async () => {
|
|
1080
|
+
const el = await badged([
|
|
1081
|
+
'--ng-border-width-1: 1px',
|
|
1082
|
+
'--ng-componentcolors-utility-gray-50: #FAFAFAFF',
|
|
1083
|
+
'--ng-componentcolors-utility-gray-200: #E9EAEBFF',
|
|
1084
|
+
'--ng-componentcolors-utility-gray-700: #414651FF',
|
|
1085
|
+
'--ng-colors-text-primary-900: #181D27FF',
|
|
1086
|
+
].join('; '));
|
|
1087
|
+
const style = getComputedStyle(part(el, 'badge'));
|
|
1088
|
+
expect(style.backgroundColor).to.equal('rgb(250, 250, 250)');
|
|
1089
|
+
expect(style.borderTopColor).to.equal('rgb(233, 234, 235)');
|
|
1090
|
+
expect(style.borderTopWidth).to.equal('1px');
|
|
1091
|
+
expect(style.color).to.equal('rgb(65, 70, 81)');
|
|
1092
|
+
});
|
|
1093
|
+
it('49. renders a description line, and reserves nothing without one', async () => {
|
|
1094
|
+
const off = await header({ pageTitle: 'Applications' });
|
|
1095
|
+
expect(off.hasAttribute('has-description')).to.be.false;
|
|
1096
|
+
expect(part(off, 'description')).to.be.null;
|
|
1097
|
+
const el = await header({
|
|
1098
|
+
pageTitle: 'Applications',
|
|
1099
|
+
description: 'Manage the apps connected to this tenant',
|
|
1100
|
+
});
|
|
1101
|
+
const line = part(el, 'description');
|
|
1102
|
+
expect(el.hasAttribute('has-description')).to.be.true;
|
|
1103
|
+
expect(line).to.exist;
|
|
1104
|
+
expect(line.textContent.trim()).to.equal('Manage the apps connected to this tenant');
|
|
1105
|
+
// Clearing the string takes the axis back off.
|
|
1106
|
+
el.description = '';
|
|
1107
|
+
await el.updateComplete;
|
|
1108
|
+
expect(el.hasAttribute('has-description')).to.be.false;
|
|
1109
|
+
expect(part(el, 'description')).to.be.null;
|
|
1110
|
+
});
|
|
1111
|
+
it('50. headerBack defaults to false and draws no arrow, and no slotted icon draws no icon', async () => {
|
|
1112
|
+
const el = await header({ pageTitle: 'Applications' });
|
|
1113
|
+
expect(el.headerBack).to.be.false;
|
|
1114
|
+
expect(el.hasAttribute('headerback')).to.be.false;
|
|
1115
|
+
expect(part(el, 'back')).to.be.null;
|
|
1116
|
+
expect(el.hasAttribute('has-app-icon')).to.be.false;
|
|
1117
|
+
expect(getComputedStyle(part(el, 'app-icon')).display).to.equal('none');
|
|
1118
|
+
expect(el.shadowRoot.querySelector('.crumb-divider')).to.be.null;
|
|
1119
|
+
});
|
|
1120
|
+
// Amendment A: the back control is the arrow alone, with no previous-page label and no slash.
|
|
1121
|
+
it('51. headerBack draws the narrow-left arrow only, then the title', async () => {
|
|
1122
|
+
const el = await header({ pageTitle: 'Applications', leading: 'back' });
|
|
1123
|
+
const back = part(el, 'back');
|
|
1124
|
+
const title = part(el, 'page-title');
|
|
1125
|
+
expect(back).to.exist;
|
|
1126
|
+
expect(back.tagName).to.equal('BUTTON');
|
|
1127
|
+
expect(back.getAttribute('type')).to.equal('button');
|
|
1128
|
+
// The icons go through nile-glyph, so assert the glyph, not an <svg>.
|
|
1129
|
+
const glyphs = back.querySelectorAll('nile-glyph');
|
|
1130
|
+
expect(glyphs.length).to.equal(1);
|
|
1131
|
+
expect(glyphs[0].getAttribute('name')).to.equal('ng-arrow-narrow-left');
|
|
1132
|
+
// No label, no divider, no slash anywhere in the header.
|
|
1133
|
+
expect(back.textContent.trim()).to.equal('');
|
|
1134
|
+
expect(back.querySelector('span')).to.be.null;
|
|
1135
|
+
expect(el.shadowRoot.querySelector('.crumb-divider')).to.be.null;
|
|
1136
|
+
expect(el.shadowRoot.querySelector('nile-glyph[name="ng-slash"]')).to.be.null;
|
|
1137
|
+
/* eslint-disable-next-line no-bitwise */
|
|
1138
|
+
const backIsFirst = back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING;
|
|
1139
|
+
expect(backIsFirst).to.be.greaterThan(0);
|
|
1140
|
+
expect(back.getBoundingClientRect().width).to.be.greaterThan(0);
|
|
1141
|
+
});
|
|
1142
|
+
// camelCase property, attribute: true + reflect: true — the title-block pattern.
|
|
1143
|
+
it('51b. headerBack and headerBackLabel work from markup and reflect to their attributes', async () => {
|
|
1144
|
+
const marked = await fixture(html `
|
|
1145
|
+
<nile-app-shell pageTitle="Applications" headerBack headerBackLabel="Retour">
|
|
1146
|
+
<p>page</p>
|
|
1147
|
+
</nile-app-shell>
|
|
1148
|
+
`);
|
|
1149
|
+
await marked.updateComplete;
|
|
1150
|
+
expect(marked.headerBack).to.be.true;
|
|
1151
|
+
expect(marked.headerBackLabel).to.equal('Retour');
|
|
1152
|
+
expect(part(marked, 'back')).to.exist;
|
|
1153
|
+
const el = await header({ pageTitle: 'Applications' });
|
|
1154
|
+
el.headerBack = true;
|
|
1155
|
+
el.headerBackLabel = 'Go back';
|
|
1156
|
+
await el.updateComplete;
|
|
1157
|
+
expect(el.hasAttribute('headerback')).to.be.true;
|
|
1158
|
+
expect(el.getAttribute('headerbacklabel')).to.equal('Go back');
|
|
1159
|
+
expect(part(el, 'back')).to.exist;
|
|
1160
|
+
el.headerBack = false;
|
|
1161
|
+
await el.updateComplete;
|
|
1162
|
+
expect(el.hasAttribute('headerback')).to.be.false;
|
|
1163
|
+
expect(part(el, 'back')).to.be.null;
|
|
1164
|
+
});
|
|
1165
|
+
// headerBackLabel names the control for assistive tech and is never drawn as text.
|
|
1166
|
+
it('52. headerBackLabel is the accessible name only, never visible text', async () => {
|
|
1167
|
+
const el = await header({ pageTitle: 'Applications', leading: 'back' });
|
|
1168
|
+
const back = part(el, 'back');
|
|
1169
|
+
expect(back.getAttribute('aria-label')).to.equal('Back');
|
|
1170
|
+
expect(back.getAttribute('title')).to.equal('Back');
|
|
1171
|
+
expect(back.querySelector('nile-glyph').getAttribute('aria-hidden')).to.not.equal('false');
|
|
1172
|
+
el.headerBackLabel = 'Integrations';
|
|
1173
|
+
await el.updateComplete;
|
|
1174
|
+
expect(back.getAttribute('aria-label')).to.equal('Integrations');
|
|
1175
|
+
// Not rendered anywhere in the header as text.
|
|
1176
|
+
expect(back.textContent.trim()).to.equal('');
|
|
1177
|
+
expect(part(el, 'title-area').textContent.includes('Integrations')).to.be.false;
|
|
1178
|
+
expect(part(el, 'top-bar').textContent.includes('Integrations')).to.be.false;
|
|
1179
|
+
});
|
|
1180
|
+
// The shell owns no history, so the control only announces the press, as the standard nile-click; the page navigates itself.
|
|
1181
|
+
it('53. the back control and goBackHeader() emit nile-click { value: "back" } from the shell', async () => {
|
|
1182
|
+
const el = await header({ pageTitle: 'Applications', leading: 'back' });
|
|
1183
|
+
let retired = 0;
|
|
1184
|
+
el.addEventListener('nile-header-back', () => {
|
|
1185
|
+
retired += 1;
|
|
1186
|
+
});
|
|
1187
|
+
const clicked = oneEvent(el, 'nile-click');
|
|
1188
|
+
part(el, 'back').click();
|
|
1189
|
+
const fromClick = (await clicked);
|
|
1190
|
+
expect(fromClick.detail).to.deep.equal({ value: 'back' });
|
|
1191
|
+
expect(fromClick.target).to.equal(el);
|
|
1192
|
+
expect(fromClick.bubbles).to.be.true;
|
|
1193
|
+
expect(fromClick.composed).to.be.true;
|
|
1194
|
+
const called = oneEvent(el, 'nile-click');
|
|
1195
|
+
el.goBackHeader();
|
|
1196
|
+
const fromCall = (await called);
|
|
1197
|
+
expect(fromCall.detail).to.deep.equal({ value: 'back' });
|
|
1198
|
+
expect(fromCall.target).to.equal(el);
|
|
1199
|
+
// nile-header-back and goBackPage() are gone in 2.0.12 (Amendment A, A-12).
|
|
1200
|
+
expect(retired).to.equal(0);
|
|
1201
|
+
expect(el.goBackPage).to.be.undefined;
|
|
1202
|
+
// It is the page's event, not the panel's: nothing about the panel moves.
|
|
1203
|
+
expect(el.panelOpen).to.be.false;
|
|
1204
|
+
});
|
|
1205
|
+
// A nile-click bubbling up from page content must not read as the header back press.
|
|
1206
|
+
it('53b. a nile-click from slotted content is distinguishable by its target', async () => {
|
|
1207
|
+
const el = await header({ pageTitle: 'Applications', leading: 'back' });
|
|
1208
|
+
const page = el.querySelector('p');
|
|
1209
|
+
const seen = [];
|
|
1210
|
+
el.addEventListener('nile-click', e => seen.push(e.target));
|
|
1211
|
+
page.dispatchEvent(new CustomEvent('nile-click', {
|
|
1212
|
+
bubbles: true,
|
|
1213
|
+
composed: true,
|
|
1214
|
+
detail: { value: 'back' },
|
|
1215
|
+
}));
|
|
1216
|
+
part(el, 'back').click();
|
|
1217
|
+
expect(seen).to.deep.equal([page, el]);
|
|
1218
|
+
});
|
|
1219
|
+
// The four combinations are independent; with an app icon the description sits under the icon, otherwise under the title.
|
|
1220
|
+
it('63. aligns the description under the app icon when there is one, else the title', async () => {
|
|
1221
|
+
for (const leading of LEADINGS) {
|
|
1222
|
+
const el = await header({
|
|
1223
|
+
pageTitle: 'Applications',
|
|
1224
|
+
description: 'Manage the apps connected to this tenant',
|
|
1225
|
+
leading,
|
|
1226
|
+
});
|
|
1227
|
+
const anchor = hasIcon(leading) ? part(el, 'app-icon') : part(el, 'page-title');
|
|
1228
|
+
const description = part(el, 'description').getBoundingClientRect();
|
|
1229
|
+
expect(Math.round(description.left), leading).to.equal(Math.round(anchor.getBoundingClientRect().left));
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
// The arrow sits beside the stack; the app icon is the first thing in the title row.
|
|
1233
|
+
it('64. renders the arrow beside the title stack and the app icon inside the title row', async () => {
|
|
1234
|
+
for (const leading of LEADINGS) {
|
|
1235
|
+
const el = await header({ pageTitle: 'Applications', leading });
|
|
1236
|
+
const row = el.shadowRoot.querySelector('.title-row');
|
|
1237
|
+
const area = part(el, 'title-area');
|
|
1238
|
+
const back = part(el, 'back');
|
|
1239
|
+
const icon = part(el, 'app-icon');
|
|
1240
|
+
expect(!!back, `${leading} back`).to.equal(hasBack(leading));
|
|
1241
|
+
expect(el.hasAttribute('has-app-icon'), `${leading} icon`).to.equal(hasIcon(leading));
|
|
1242
|
+
if (back) {
|
|
1243
|
+
expect(row.contains(back), leading).to.be.false;
|
|
1244
|
+
expect(area.contains(back), leading).to.be.true;
|
|
1245
|
+
}
|
|
1246
|
+
expect(row.contains(icon), leading).to.be.true;
|
|
1247
|
+
if (hasIcon(leading)) {
|
|
1248
|
+
expect(row.firstElementChild, leading).to.equal(icon);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
// The arrow sits on the title's row, and its offset does not move when a description appears.
|
|
1253
|
+
it('65. sits the back arrow on the title row, description or not', async () => {
|
|
1254
|
+
for (const leading of ['back', 'back+app-icon']) {
|
|
1255
|
+
for (const description of ['', 'Manage the apps connected here']) {
|
|
1256
|
+
const el = await header({
|
|
1257
|
+
pageTitle: 'Applications',
|
|
1258
|
+
leading,
|
|
1259
|
+
description,
|
|
1260
|
+
});
|
|
1261
|
+
const title = part(el, 'page-title').getBoundingClientRect();
|
|
1262
|
+
const box = el.shadowRoot.querySelector('.back-line');
|
|
1263
|
+
const label = `${leading} description=${!!description}`;
|
|
1264
|
+
// The line box is the title's own, and it starts where the title does.
|
|
1265
|
+
expect(Math.round(box.getBoundingClientRect().top), label).to.equal(Math.round(title.top));
|
|
1266
|
+
expect(Math.round(box.getBoundingClientRect().height), label).to.equal(24);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
// Figma Title Area: arrow → 16px → [icon → 8px → title]; Breadcrumb / 01: toggle glyph → 16px → divider (16px tall) → 16px.
|
|
1271
|
+
it('65b. spaces the toggle, divider, arrow, icon and title as the design does', async () => {
|
|
1272
|
+
const el = await header({ pageTitle: 'Applications', leading: 'back+app-icon' });
|
|
1273
|
+
const toggle = part(el, 'nav-toggle');
|
|
1274
|
+
const glyph = toggle.querySelector('nile-glyph') ?? toggle;
|
|
1275
|
+
const rule = part(el, 'rule').getBoundingClientRect();
|
|
1276
|
+
const backGlyph = part(el, 'back').querySelector('nile-glyph').getBoundingClientRect();
|
|
1277
|
+
const icon = part(el, 'app-icon').getBoundingClientRect();
|
|
1278
|
+
const title = part(el, 'page-title').getBoundingClientRect();
|
|
1279
|
+
const toggleGlyph = glyph.getBoundingClientRect();
|
|
1280
|
+
expect(Math.round(rule.height)).to.equal(16);
|
|
1281
|
+
expect(Math.round(rule.left - toggleGlyph.right)).to.equal(16);
|
|
1282
|
+
expect(Math.round(backGlyph.left - rule.left)).to.equal(16);
|
|
1283
|
+
expect(Math.round(backGlyph.width)).to.equal(20);
|
|
1284
|
+
expect(Math.round(icon.left - backGlyph.right)).to.equal(16);
|
|
1285
|
+
expect(Math.round(title.left - icon.right)).to.equal(8);
|
|
1286
|
+
// Without an icon the title takes the icon's place, 16px after the arrow.
|
|
1287
|
+
const plain = await header({ pageTitle: 'Applications', leading: 'back' });
|
|
1288
|
+
const plainArrow = part(plain, 'back').querySelector('nile-glyph').getBoundingClientRect();
|
|
1289
|
+
const plainTitle = part(plain, 'page-title').getBoundingClientRect();
|
|
1290
|
+
expect(Math.round(plainTitle.left - plainArrow.right)).to.equal(16);
|
|
1291
|
+
});
|
|
1292
|
+
// The app-icon slot works with or without headerBack, and with or without a title.
|
|
1293
|
+
it('65c. shows a slotted app icon independently of headerBack', async () => {
|
|
1294
|
+
const iconOnly = await header({ pageTitle: 'Applications', leading: 'app-icon' });
|
|
1295
|
+
expect(part(iconOnly, 'back')).to.be.null;
|
|
1296
|
+
expect(part(iconOnly, 'app-icon').getBoundingClientRect().width).to.equal(20);
|
|
1297
|
+
// An icon on its own still mounts the title block.
|
|
1298
|
+
const bare = await fixture(html `
|
|
1299
|
+
<nile-app-shell style="width: 1176px; height: 800px; ${HEADER_TOKENS}">
|
|
1300
|
+
<nav slot="side-nav">nav</nav>
|
|
1301
|
+
<span slot="app-icon" id="mark">A</span>
|
|
1302
|
+
<p>page</p>
|
|
1303
|
+
</nile-app-shell>
|
|
1304
|
+
`);
|
|
1305
|
+
await waitUntil(() => bare.hasAttribute('has-app-icon'));
|
|
1306
|
+
await bare.updateComplete;
|
|
1307
|
+
expect(part(bare, 'title-area')).to.exist;
|
|
1308
|
+
expect(part(bare, 'app-icon').getBoundingClientRect().width).to.equal(20);
|
|
1309
|
+
// Removing the mark takes the icon (and the block it alone mounted) away again.
|
|
1310
|
+
bare.querySelector('#mark').remove();
|
|
1311
|
+
await waitUntil(() => !bare.hasAttribute('has-app-icon'));
|
|
1312
|
+
await bare.updateComplete;
|
|
1313
|
+
expect(part(bare, 'title-area')).to.be.null;
|
|
1314
|
+
});
|
|
1315
|
+
// Amendment A (M11): the inactive tab colour is scoped to the shell's strip; a standalone group keeps its defaults.
|
|
1316
|
+
it('65e. colours the inactive header tabs through the shell only', async () => {
|
|
1317
|
+
const colours = [
|
|
1318
|
+
'--ng-colors-fg-brand-primary-alt: rgb(21, 94, 239)',
|
|
1319
|
+
'--ng-colors-text-brand-secondary-700: rgb(0, 78, 235)',
|
|
1320
|
+
'--ng-colors-text-quaternary-500: rgb(113, 118, 128)',
|
|
1321
|
+
'--ng-colors-text-secondary-700: rgb(65, 70, 81)',
|
|
1322
|
+
].join('; ');
|
|
1323
|
+
const el = await header({ pageTitle: 'Applications', tabs: true });
|
|
1324
|
+
el.setAttribute('style', `${el.getAttribute('style')}; ${colours}`);
|
|
1325
|
+
await el.updateComplete;
|
|
1326
|
+
const group = el.querySelector('nile-nav-tab-group');
|
|
1327
|
+
const inactive = el.querySelectorAll('nile-nav-tab')[1];
|
|
1328
|
+
// Known limitation (M12, A-7): the shell sets no indicator colour, because nile-nav-tab-group's top indicator
|
|
1329
|
+
// hard-codes its token in its own shadow tree; the indicator keeps the group's default.
|
|
1330
|
+
const indicatorOf = (g) => getComputedStyle(g.shadowRoot.querySelector('.nav-tab-group__indicator'))
|
|
1331
|
+
.borderBottomColor;
|
|
1332
|
+
expect(indicatorOf(group)).to.equal('rgb(0, 78, 235)');
|
|
1333
|
+
// The inactive label is remapped inside the shell only: nile-nav-tab itself is unchanged and still paints
|
|
1334
|
+
// var(--nile-colors-dark-500, var(--ng-colors-text-secondary-700)); the shell points that fallback token at quaternary-500.
|
|
1335
|
+
// (The painted ink sits inside nile-link, which this runner does not render; it is checked in the browser instead.)
|
|
1336
|
+
const secondaryOf = (node) => getComputedStyle(node).getPropertyValue('--ng-colors-text-secondary-700').trim();
|
|
1337
|
+
expect(secondaryOf(inactive)).to.equal('rgb(113, 118, 128)');
|
|
1338
|
+
const sheets = customElements.get('nile-nav-tab').styles;
|
|
1339
|
+
const tabStyles = [].concat(sheets).map(sheet => sheet.cssText).join('\n');
|
|
1340
|
+
expect(tabStyles).to.include('color: var(--nile-colors-dark-500, var(--ng-colors-text-secondary-700));');
|
|
1341
|
+
expect(tabStyles, 'no new hook on the shared component').to.not.include('--nile-nav-tab-text-color:');
|
|
1342
|
+
// Nothing else in the shell's tree is remapped.
|
|
1343
|
+
expect(secondaryOf(el)).to.equal('rgb(65, 70, 81)');
|
|
1344
|
+
// Outside the shell nothing changes.
|
|
1345
|
+
const standalone = await fixture(html `
|
|
1346
|
+
<nile-nav-tab-group style="${colours}">
|
|
1347
|
+
<nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
|
|
1348
|
+
<nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
|
|
1349
|
+
</nile-nav-tab-group>
|
|
1350
|
+
`);
|
|
1351
|
+
const loneTab = standalone.querySelectorAll('nile-nav-tab')[1];
|
|
1352
|
+
expect(secondaryOf(loneTab), 'the global default is untouched').to.equal('rgb(65, 70, 81)');
|
|
1353
|
+
await standalone.updateComplete;
|
|
1354
|
+
expect(indicatorOf(standalone), 'a standalone group paints its own default').to.equal('rgb(0, 78, 235)');
|
|
1355
|
+
});
|
|
1356
|
+
it('65d. colours the description text-quaternary-500', async () => {
|
|
1357
|
+
const el = await header({
|
|
1358
|
+
pageTitle: 'Applications',
|
|
1359
|
+
description: 'Manage the apps connected here',
|
|
1360
|
+
});
|
|
1361
|
+
el.style.setProperty('--ng-colors-text-quaternary-500', 'rgb(113, 118, 128)');
|
|
1362
|
+
el.style.setProperty('--ng-colors-text-tertiary-600', 'rgb(1, 2, 3)');
|
|
1363
|
+
expect(getComputedStyle(part(el, 'description')).color).to.equal('rgb(113, 118, 128)');
|
|
1364
|
+
});
|
|
1365
|
+
it('66. centres a 20px app icon in the 24px title row, two pixels down', async () => {
|
|
1366
|
+
const el = await header({
|
|
1367
|
+
pageTitle: 'Applications',
|
|
1368
|
+
leading: 'app-icon',
|
|
1369
|
+
description: 'Manage the apps connected here',
|
|
1370
|
+
});
|
|
1371
|
+
const title = part(el, 'page-title').getBoundingClientRect();
|
|
1372
|
+
const icon = part(el, 'app-icon').getBoundingClientRect();
|
|
1373
|
+
// (24 - 20) / 2 — structural, not an offset written anywhere.
|
|
1374
|
+
expect(Math.round(icon.top - title.top)).to.equal(2);
|
|
1375
|
+
});
|
|
1376
|
+
// The app icon is sized apart from the utility controls, so retuning one cannot move the other.
|
|
1377
|
+
it('67. sizes the app icon independently of the utility controls', async () => {
|
|
1378
|
+
const el = await header({ pageTitle: 'Applications', leading: 'app-icon' });
|
|
1379
|
+
const host = getComputedStyle(el);
|
|
1380
|
+
expect(host.getPropertyValue('--nile-app-shell-app-icon-size').trim()).to.equal('20px');
|
|
1381
|
+
expect(host.getPropertyValue('--nile-app-shell-utility-size').trim()).to.equal('32px');
|
|
1382
|
+
el.style.setProperty('--nile-app-shell-app-icon-size', '28px');
|
|
1383
|
+
await el.updateComplete;
|
|
1384
|
+
expect(part(el, 'app-icon').getBoundingClientRect().width).to.equal(28);
|
|
1385
|
+
// The nav toggle is untouched by that.
|
|
1386
|
+
const toggle = part(el, 'nav-toggle');
|
|
1387
|
+
const base = toggle.shadowRoot.querySelector('[part~="base"]');
|
|
1388
|
+
expect(base.getBoundingClientRect().width).to.equal(32);
|
|
1389
|
+
});
|
|
1390
|
+
it('54. a slotted app icon draws a 20px block around the mark', async () => {
|
|
1391
|
+
const el = await header({ pageTitle: 'Applications', leading: 'app-icon' });
|
|
1392
|
+
const box = part(el, 'app-icon');
|
|
1393
|
+
expect(box).to.exist;
|
|
1394
|
+
expect(part(el, 'back')).to.be.null;
|
|
1395
|
+
const rect = box.getBoundingClientRect();
|
|
1396
|
+
// Its own size, not the 32px utility control box the nav toggle uses.
|
|
1397
|
+
expect(rect.width).to.equal(20);
|
|
1398
|
+
expect(rect.height).to.equal(20);
|
|
1399
|
+
// The page's mark is assigned into the shell's box.
|
|
1400
|
+
const slot = box.querySelector('slot[name="app-icon"]');
|
|
1401
|
+
expect(slot).to.exist;
|
|
1402
|
+
expect(slot.assignedElements()).to.deep.equal([
|
|
1403
|
+
el.querySelector('#mark'),
|
|
1404
|
+
]);
|
|
1405
|
+
// The block sits before the title, not after it.
|
|
1406
|
+
/* eslint-disable-next-line no-bitwise */
|
|
1407
|
+
const boxIsFirst = box.compareDocumentPosition(part(el, 'page-title')) &
|
|
1408
|
+
Node.DOCUMENT_POSITION_FOLLOWING;
|
|
1409
|
+
expect(boxIsFirst).to.be.greaterThan(0);
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
// The four header heights are viewport-height sensitive, so these tests set a viewport where the spec numbers apply.
|
|
1413
|
+
describe('NileAppShell — the four header heights', () => {
|
|
1414
|
+
const TALL = { width: 1400, height: 900 };
|
|
1415
|
+
const SHORT = { width: 800, height: 600 };
|
|
1416
|
+
before(async () => {
|
|
1417
|
+
await setViewport(TALL);
|
|
1418
|
+
});
|
|
1419
|
+
after(async () => {
|
|
1420
|
+
await setViewport(SHORT);
|
|
1421
|
+
});
|
|
1422
|
+
const bar = (el) => el.shadowRoot.querySelector('.container > .top-bar');
|
|
1423
|
+
const strip = (el) => el.shadowRoot.querySelector('.tabs');
|
|
1424
|
+
// The header block is the bar plus its strip, measured top-to-bottom so a gap between them is caught.
|
|
1425
|
+
const headerBlock = (el) => {
|
|
1426
|
+
const top = bar(el).getBoundingClientRect().top;
|
|
1427
|
+
const bottom = el.tabsVisible
|
|
1428
|
+
? strip(el).getBoundingClientRect().bottom
|
|
1429
|
+
: bar(el).getBoundingClientRect().bottom;
|
|
1430
|
+
return bottom - top;
|
|
1431
|
+
};
|
|
1432
|
+
it('55. is 68px with neither axis on, whatever the leading', async () => {
|
|
1433
|
+
for (const leading of LEADINGS) {
|
|
1434
|
+
const el = await header({ pageTitle: 'Applications', leading });
|
|
1435
|
+
expect(bar(el).getBoundingClientRect().height, leading).to.equal(68);
|
|
1436
|
+
// The leading axis changes what is in the bar, never how tall it is.
|
|
1437
|
+
const area = part(el, 'title-area').getBoundingClientRect().height;
|
|
1438
|
+
expect(area, leading).to.be.at.most(bar(el).clientHeight);
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
// A description adds its own line (24 title + 2 gap + 18 description = 44) in a 76px bar; asserted as the lines' own boxes.
|
|
1442
|
+
it('56. is 76px with a description, and the second line fits it', async () => {
|
|
1443
|
+
const el = await header({
|
|
1444
|
+
pageTitle: 'Applications',
|
|
1445
|
+
description: 'Manage the apps connected to this tenant',
|
|
1446
|
+
});
|
|
1447
|
+
expect(bar(el).getBoundingClientRect().height).to.equal(76);
|
|
1448
|
+
const stack = el.shadowRoot.querySelector('.title-stack');
|
|
1449
|
+
expect(part(el, 'page-title').getBoundingClientRect().height).to.equal(24);
|
|
1450
|
+
expect(part(el, 'description').getBoundingClientRect().height).to.equal(18);
|
|
1451
|
+
expect(stack.getBoundingClientRect().height).to.equal(44);
|
|
1452
|
+
// Nothing is clipped: the stack sits wholly inside the bar's box.
|
|
1453
|
+
const barBox = bar(el).getBoundingClientRect();
|
|
1454
|
+
const stackBox = stack.getBoundingClientRect();
|
|
1455
|
+
expect(stackBox.top).to.be.at.least(barBox.top);
|
|
1456
|
+
expect(stackBox.bottom).to.be.at.most(barBox.bottom);
|
|
1457
|
+
});
|
|
1458
|
+
it('57. adds a 33px strip for tabs: 101px, and 109px with a description', async () => {
|
|
1459
|
+
const tabbed = await header({ pageTitle: 'Applications', tabs: true });
|
|
1460
|
+
expect(strip(tabbed).getBoundingClientRect().height).to.equal(33);
|
|
1461
|
+
expect(headerBlock(tabbed)).to.equal(101);
|
|
1462
|
+
const described = await header({
|
|
1463
|
+
pageTitle: 'Applications',
|
|
1464
|
+
description: 'Manage the apps connected to this tenant',
|
|
1465
|
+
tabs: true,
|
|
1466
|
+
});
|
|
1467
|
+
expect(strip(described).getBoundingClientRect().height).to.equal(33);
|
|
1468
|
+
expect(headerBlock(described)).to.equal(109);
|
|
1469
|
+
});
|
|
1470
|
+
// The three axes are independent, so all twelve combinations land on one of the four heights.
|
|
1471
|
+
it('58. resolves every variant to exactly the four heights', async () => {
|
|
1472
|
+
for (const leading of LEADINGS) {
|
|
1473
|
+
for (const tabs of [false, true]) {
|
|
1474
|
+
for (const described of [false, true]) {
|
|
1475
|
+
/* eslint-disable-next-line no-nested-ternary */
|
|
1476
|
+
const expected = tabs ? (described ? 109 : 101) : described ? 76 : 68;
|
|
1477
|
+
const el = await header({
|
|
1478
|
+
pageTitle: 'Applications',
|
|
1479
|
+
description: described ? 'Manage connected apps' : '',
|
|
1480
|
+
leading,
|
|
1481
|
+
tabs,
|
|
1482
|
+
});
|
|
1483
|
+
const label = `leading=${leading} tabs=${tabs} description=${described}`;
|
|
1484
|
+
expect(headerBlock(el), label).to.equal(expected);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
// The strip is a control row plus its rule; the row shrinks to fit via the group's own padding hook, staying shorter than the standalone group.
|
|
1490
|
+
it('59. shrinks the slotted tab row to fit the strip, rather than crop it', async () => {
|
|
1491
|
+
const el = await header({ pageTitle: 'Applications', tabs: true });
|
|
1492
|
+
const standalone = await fixture(html `
|
|
1493
|
+
<nile-nav-tab-group style="${HEADER_TOKENS}">
|
|
1494
|
+
<nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
|
|
1495
|
+
<nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
|
|
1496
|
+
</nile-nav-tab-group>
|
|
1497
|
+
`);
|
|
1498
|
+
const stripBox = strip(el).getBoundingClientRect();
|
|
1499
|
+
const slottedRow = el.querySelector('nile-nav-tab-group').getBoundingClientRect();
|
|
1500
|
+
const loneRow = standalone.getBoundingClientRect();
|
|
1501
|
+
// The design's row: 0 + 20px label + 12px = 32, in a 33px strip.
|
|
1502
|
+
expect(slottedRow.height).to.equal(32);
|
|
1503
|
+
expect(stripBox.height).to.equal(33);
|
|
1504
|
+
// The shell's hook is load-bearing: the same group is taller on its own.
|
|
1505
|
+
expect(loneRow.height).to.be.greaterThan(slottedRow.height);
|
|
1506
|
+
expect(loneRow.height).to.be.greaterThan(stripBox.height);
|
|
1507
|
+
// Nothing is cropped and there is no slack: the row fills the strip above the rule it sits on.
|
|
1508
|
+
expect(slottedRow.top).to.be.at.least(stripBox.top);
|
|
1509
|
+
expect(slottedRow.bottom).to.be.at.most(stripBox.bottom);
|
|
1510
|
+
expect(slottedRow.top - stripBox.top).to.equal(0);
|
|
1511
|
+
});
|
|
1512
|
+
it('60. drives the row through the group’s own padding hook', async () => {
|
|
1513
|
+
const el = await header({ pageTitle: 'Applications', tabs: true });
|
|
1514
|
+
const group = el.querySelector('nile-nav-tab-group');
|
|
1515
|
+
const padding = getComputedStyle(group)
|
|
1516
|
+
.getPropertyValue('--nile-nav-tab-padding')
|
|
1517
|
+
.split(/\s+/)
|
|
1518
|
+
.filter(Boolean);
|
|
1519
|
+
// The tab node's padding is 0 / 4 / 12 / 4, the 12px bottom being the active indicator's band.
|
|
1520
|
+
expect(padding).to.deep.equal(['0px', '4px', '12px', '4px']);
|
|
1521
|
+
// The inter-tab gap is the group's own 12px and is deliberately untouched.
|
|
1522
|
+
expect(getComputedStyle(group).getPropertyValue('--tab-gap').trim()).to.equal('12px');
|
|
1523
|
+
});
|
|
1524
|
+
// The compression under a short viewport is documented behaviour, asserted here to keep the resize honest.
|
|
1525
|
+
it('61. compresses to 56/64px under 600px of viewport height', async () => {
|
|
1526
|
+
await setViewport(SHORT);
|
|
1527
|
+
try {
|
|
1528
|
+
const plain = await header({ pageTitle: 'Applications' });
|
|
1529
|
+
expect(bar(plain).getBoundingClientRect().height).to.equal(56);
|
|
1530
|
+
const described = await header({
|
|
1531
|
+
pageTitle: 'Applications',
|
|
1532
|
+
description: 'Manage connected apps',
|
|
1533
|
+
});
|
|
1534
|
+
expect(bar(described).getBoundingClientRect().height).to.equal(64);
|
|
1535
|
+
}
|
|
1536
|
+
finally {
|
|
1537
|
+
await setViewport(TALL);
|
|
1538
|
+
}
|
|
1539
|
+
});
|
|
453
1540
|
});
|
|
454
1541
|
//# sourceMappingURL=nile-app-shell.test.js.map
|