diff --git a/apps/tests/accessibility.spec.ts b/apps/tests/accessibility.spec.ts index 7cca5c17..154d4048 100644 --- a/apps/tests/accessibility.spec.ts +++ b/apps/tests/accessibility.spec.ts @@ -1,9 +1,14 @@ -import { test, expect } from "@playwright/test"; +import { expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; +import { test } from "./fixtures"; + test.describe("accessibility", () => { ["light", "dark"].forEach((colorScheme: "light" | "dark") => { - test(`no contrast issues in ${colorScheme} mode`, async ({ page }) => { + test(`no contrast issues in ${colorScheme} mode`, async ({ + page, + navigationBar, + }) => { await test.step(`given the preferred color scheme is set to ${colorScheme}`, async () => { await page.emulateMedia({ colorScheme }); }); @@ -11,11 +16,9 @@ test.describe("accessibility", () => { await test.step("when viewing a typical page in PodOS Browser", async () => { await page.goto("/"); - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill( + await navigationBar.fillAndSubmit( "http://localhost:4000/alice/public/generic/resource#it", ); - await navigationBar.press("Enter"); const heading = page.getByRole("heading"); await expect(heading).toHaveText("Something"); diff --git a/apps/tests/add-literal-value.spec.ts b/apps/tests/add-literal-value.spec.ts index 1b18a957..93d5cc07 100644 --- a/apps/tests/add-literal-value.spec.ts +++ b/apps/tests/add-literal-value.spec.ts @@ -1,17 +1,17 @@ -import { expect, test } from "@playwright/test"; +import { expect } from "@playwright/test"; import { signIn } from "./actions/signIn"; import { alice } from "./fixtures/credentials"; -test("can add a literal value", async ({ page }) => { +import { test } from "./fixtures"; + +test("can add a literal value", async ({ page, navigationBar }) => { // when opening PodOS Browser await page.goto("/"); // and navigating to a generic resource - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill( + await navigationBar.fillAndSubmit( "http://localhost:4000/alice/public/generic/resource#it", ); - await navigationBar.press("Enter"); // then I cannot see any input to add literal values const missingAddLiteralField = page.getByPlaceholder("Add literal"); diff --git a/apps/tests/add-new-thing.spec.ts b/apps/tests/add-new-thing.spec.ts index a08f42a9..ef5d5e8a 100644 --- a/apps/tests/add-new-thing.spec.ts +++ b/apps/tests/add-new-thing.spec.ts @@ -1,17 +1,16 @@ -import { expect, test } from "@playwright/test"; +import { expect } from "@playwright/test"; import { signIn } from "./actions/signIn"; import { alice } from "./fixtures/credentials"; +import { test } from "./fixtures"; -test("can add a new thing", async ({ page }) => { +test("can add a new thing", async ({ page, navigationBar }) => { // when opening PodOS Browser await page.goto("/"); // and navigating to a container - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill( + await navigationBar.fillAndSubmit( "http://localhost:4000/alice/acb50d31-42af-4d4c-9ead-e2d5e70d7317/", ); - await navigationBar.press("Enter"); // when signing in as the pod owner await signIn(page, alice); @@ -37,7 +36,7 @@ test("can add a new thing", async ({ page }) => { await page.keyboard.press("Enter"); // then I am at the page showing the new thing - await expect(navigationBar).toHaveValue( + expect(await navigationBar.inputValue()).toEqual( "http://localhost:4000/alice/acb50d31-42af-4d4c-9ead-e2d5e70d7317/my-new-thing#it", ); diff --git a/apps/tests/fixtures/index.ts b/apps/tests/fixtures/index.ts new file mode 100644 index 00000000..56b061eb --- /dev/null +++ b/apps/tests/fixtures/index.ts @@ -0,0 +1,10 @@ +import { test as base } from "playwright/test"; +import { NavigationBar } from "../page-objects/NavigationBar"; + +export const test = base.extend<{ + navigationBar: NavigationBar; +}>({ + navigationBar: async ({ page }, use) => { + await use(new NavigationBar(page)); + }, +}); diff --git a/apps/tests/generic.spec.ts b/apps/tests/generic.spec.ts index 4c4fdae5..383b9a74 100644 --- a/apps/tests/generic.spec.ts +++ b/apps/tests/generic.spec.ts @@ -1,17 +1,18 @@ -import { test, expect } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import { test } from "./fixtures"; test("show generic information about unknown types of things", async ({ page, + navigationBar, }) => { // when opening PodOS Browser await page.goto("/"); // and navigating to a generic resource - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill( + await navigationBar.fillAndSubmit( "http://localhost:4000/alice/public/generic/resource#it", ); - await navigationBar.press("Enter"); // then page shows a heading with the resource name const heading = page.getByRole("heading"); diff --git a/apps/tests/ldp-container.spec.ts b/apps/tests/ldp-container.spec.ts index de9b62a7..926eab87 100644 --- a/apps/tests/ldp-container.spec.ts +++ b/apps/tests/ldp-container.spec.ts @@ -1,13 +1,13 @@ -import { test, expect } from "@playwright/test"; +import { expect } from "@playwright/test"; -test("show contents of an LDP container", async ({ page }) => { +import { test } from "./fixtures"; + +test("show contents of an LDP container", async ({ page, navigationBar }) => { // when opening PodOS Browser await page.goto("/"); // and navigating to a ldp container resource - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill("http://localhost:4000/alice/container/"); - await navigationBar.press("Enter"); + await navigationBar.fillAndSubmit("http://localhost:4000/alice/container/"); // then page shows the full container URL as heading const heading = await page.getByRole("heading", { level: 1 }); diff --git a/apps/tests/page-objects/NavigationBar.ts b/apps/tests/page-objects/NavigationBar.ts new file mode 100644 index 00000000..5d977c81 --- /dev/null +++ b/apps/tests/page-objects/NavigationBar.ts @@ -0,0 +1,37 @@ +import { Locator, Page } from "@playwright/test"; + +export class NavigationBar { + private readonly nav: Locator; + private readonly close: () => Promise; + + constructor(private page: Page) { + this.nav = page.getByRole("navigation"); + this.close = () => page.keyboard.press("Escape"); + } + + async fill(text: string) { + const input = await this.activateInput(); + await input.fill(text); + return input; + } + + async fillAndSubmit(text: string) { + const input = await this.fill(text); + await input.press("Enter"); + } + + async activateInput() { + const button = this.nav.getByRole("button", { + name: "Search or enter URI", + }); + await button.click(); + return this.page.getByPlaceholder("Search or enter URI"); + } + + async inputValue() { + const input = await this.activateInput(); + const value = await input.inputValue(); + await this.close(); + return value; + } +} diff --git a/apps/tests/person.spec.ts b/apps/tests/person.spec.ts index 9523764c..0f93cc92 100644 --- a/apps/tests/person.spec.ts +++ b/apps/tests/person.spec.ts @@ -1,13 +1,15 @@ -import { test, expect } from "@playwright/test"; +import { expect } from "@playwright/test"; -test("show name as heading for a person", async ({ page }) => { +import { test } from "./fixtures"; + +test("show name as heading for a person", async ({ page, navigationBar }) => { // when opening PodOS Browser await page.goto("/"); // and navigating to Alice's WebID - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill("http://localhost:4000/alice/profile/card#me"); - await navigationBar.press("Enter"); + await navigationBar.fillAndSubmit( + "http://localhost:4000/alice/profile/card#me", + ); // then the heading shows Alice's name as heading const label = await page.getByRole("heading"); diff --git a/apps/tests/reading-while-offline.spec.ts b/apps/tests/reading-while-offline.spec.ts index 75b694ba..36927105 100644 --- a/apps/tests/reading-while-offline.spec.ts +++ b/apps/tests/reading-while-offline.spec.ts @@ -1,9 +1,12 @@ -import { test, expect } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import { test } from "./fixtures"; test("can access cached resources while offline", async ({ page, context, browserName, + navigationBar, }) => { test.skip( browserName === "webkit", @@ -17,11 +20,9 @@ test("can access cached resources while offline", async ({ }); await test.step("and the they have visited a resource", async () => { - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill( + await navigationBar.fillAndSubmit( "http://localhost:4000/alice/public/generic/resource#it", ); - await navigationBar.press("Enter"); const heading = page.getByRole("heading"); await expect(heading).toHaveText("Something"); @@ -36,11 +37,9 @@ test("can access cached resources while offline", async ({ }); await test.step("and visits the previously loaded resource", async () => { - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill( + await navigationBar.fillAndSubmit( "http://localhost:4000/alice/public/generic/resource#it", ); - await navigationBar.press("Enter"); }); await test.step("then the cached content is still accessible", async () => { diff --git a/apps/tests/text-search.spec.ts b/apps/tests/text-search.spec.ts index 2a07932f..6cd10f64 100644 --- a/apps/tests/text-search.spec.ts +++ b/apps/tests/text-search.spec.ts @@ -1,24 +1,27 @@ -import { test, expect } from "@playwright/test"; +import { expect } from "@playwright/test"; import { signIn } from "./actions/signIn"; import { alice } from "./fixtures/credentials"; +import { test } from "./fixtures"; test.describe("Text search", () => { - test("finds a thing based on the label index", async ({ page }) => { + test("finds a thing based on the label index", async ({ + page, + navigationBar, + }) => { await test.step("given a user is signed in", async () => { await page.goto("/"); await signIn(page, alice); }); await test.step("when they search for a text and select the first result", async () => { - const navigationBar = page.getByPlaceholder("Search or enter URI"); - await navigationBar.fill("ometh"); + const input = await navigationBar.fill("ometh"); const result = page .getByRole("listitem") .filter({ hasText: "Something" }); await expect(result).toBeVisible(); - await navigationBar.press("ArrowDown"); - await navigationBar.press("Enter"); + await input.press("ArrowDown"); + await input.press("Enter"); }); await test.step("then the page shows the selected resource", async () => { @@ -29,26 +32,27 @@ test.describe("Text search", () => { }); }); - test("can find a thing, after making it findable", async ({ page }) => { + test("can find a thing, after making it findable", async ({ + page, + navigationBar, + }) => { await test.step("given a user is signed in", async () => { await page.goto("/"); await signIn(page, alice); }); + const input = await navigationBar.activateInput(); + await test.step("and there is no search result for 'Banana'", async () => { - const navigationBar = page.getByPlaceholder("Search or enter URI"); - await navigationBar.fill("Banana"); + await input.fill("Banana"); const result = page.getByRole("listitem").filter({ hasText: "Banana" }); await expect(result).not.toBeVisible(); }); await test.step("when they navigate to the thing", async () => { - const navigationBar = page.getByPlaceholder("Enter URI"); - await navigationBar.fill( - "http://localhost:4000/alice/make-findable/banana#it", - ); - await navigationBar.press("Enter"); + await input.fill("http://localhost:4000/alice/make-findable/banana#it"); + await input.press("Enter"); await expect(page.getByRole("heading", { name: "Banana" })).toBeVisible(); }); @@ -57,7 +61,6 @@ test.describe("Text search", () => { }); await test.step("then they will find it when retrying the search", async () => { - const navigationBar = page.getByPlaceholder("Search or enter URI"); await navigationBar.fill("Banana"); const result = page.getByRole("listitem").filter({ hasText: "Banana" }); diff --git a/docs/elements/apps/pos-app-browser/readme.md b/docs/elements/apps/pos-app-browser/readme.md index 219a2574..70fa11c6 100644 --- a/docs/elements/apps/pos-app-browser/readme.md +++ b/docs/elements/apps/pos-app-browser/readme.md @@ -7,10 +7,10 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -------------- | -| `mode` | `mode` | The mode the app is running in: - standalone: use this when you deploy it as a standalone web application - pod: use this when you host this app as a default interface for you pod | `"pod" \| "standalone"` | `'standalone'` | -| `restorePreviousSession` | `restore-previous-session` | | `boolean` | `false` | +| Property | Attribute | Description | Type | Default | +| ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- | -------------- | +| `mode` | `mode` | The mode the app is running in: - standalone: use this when you deploy it as a standalone web application - pod: use this when you host this app as a default interface for you pod | `"pod" \| "standalone"` | `'standalone'` | +| `restorePreviousSession` | `restore-previous-session` | | `boolean` | `false` | ## Dependencies @@ -21,7 +21,7 @@ - [pos-error-toast](../../components/pos-error-toast) - [pos-router](../../components/pos-router) - [pos-add-new-thing](../../components/pos-add-new-thing) -- [pos-navigation-bar](../../components/pos-navigation-bar) +- [pos-navigation](../../components/pos-navigation) - [pos-login](../../components/pos-login) - [pos-internal-router](../../components/pos-internal-router) - [pos-resource](../../components/pos-resource) @@ -34,7 +34,7 @@ graph TD; pos-app-browser --> pos-error-toast pos-app-browser --> pos-router pos-app-browser --> pos-add-new-thing - pos-app-browser --> pos-navigation-bar + pos-app-browser --> pos-navigation pos-app-browser --> pos-login pos-app-browser --> pos-internal-router pos-app-browser --> pos-resource @@ -48,13 +48,12 @@ graph TD; pos-add-new-thing --> pos-new-thing-form pos-dialog --> ion-icon pos-new-thing-form --> pos-select-term + pos-navigation --> pos-navigation-bar + pos-navigation --> pos-rich-link pos-navigation-bar --> pos-make-findable - pos-navigation-bar --> ion-searchbar - pos-navigation-bar --> pos-rich-link pos-make-findable --> pos-resource pos-make-findable --> pos-label pos-resource --> ion-progress-bar - ion-searchbar --> ion-icon pos-rich-link --> pos-resource pos-rich-link --> pos-label pos-rich-link --> pos-description diff --git a/docs/elements/components/pos-container-contents/readme.md b/docs/elements/components/pos-container-contents/readme.md index 8a0c015e..4328d3a9 100644 --- a/docs/elements/components/pos-container-contents/readme.md +++ b/docs/elements/components/pos-container-contents/readme.md @@ -9,7 +9,6 @@ | Event | Description | Type | | ----------------- | ----------- | ------------------ | -| `pod-os:link` | | `CustomEvent` | | `pod-os:resource` | | `CustomEvent` | @@ -17,18 +16,22 @@ ### Used by - - [pos-container-contents](.) + - [pos-app-ldp-container](../../apps/pos-app-ldp-container) ### Depends on -- ion-icon +- [pos-resource](../pos-resource) +- [pos-container-item](.) ### Graph ```mermaid graph TD; - pos-container-item --> ion-icon + pos-container-contents --> pos-resource pos-container-contents --> pos-container-item - style pos-container-item fill:#f9f,stroke:#333,stroke-width:4px + pos-resource --> ion-progress-bar + pos-container-item --> ion-icon + pos-app-ldp-container --> pos-container-contents + style pos-container-contents fill:#f9f,stroke:#333,stroke-width:4px ``` ---------------------------------------------- diff --git a/docs/elements/components/pos-make-findable/readme.md b/docs/elements/components/pos-make-findable/readme.md index 117053c0..3324ada6 100644 --- a/docs/elements/components/pos-make-findable/readme.md +++ b/docs/elements/components/pos-make-findable/readme.md @@ -23,7 +23,7 @@ ### Used by - - [pos-navigation-bar](../pos-navigation-bar) + - [pos-navigation-bar](../pos-navigation/bar) ### Depends on diff --git a/docs/elements/components/pos-navigation/bar/readme.md b/docs/elements/components/pos-navigation/bar/readme.md new file mode 100644 index 00000000..edcf56fb --- /dev/null +++ b/docs/elements/components/pos-navigation/bar/readme.md @@ -0,0 +1,43 @@ + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------------ | -------------------- | ----------- | --------- | ----------- | +| `current` | -- | | `Thing` | `undefined` | +| `searchIndexReady` | `search-index-ready` | | `boolean` | `undefined` | + + +## Events + +| Event | Description | Type | +| ----------------- | ----------- | ------------------ | +| `pod-os:navigate` | | `CustomEvent` | + + +## Dependencies + +### Used by + + - [pos-navigation](..) + +### Depends on + +- [pos-make-findable](../../pos-make-findable) + +### Graph +```mermaid +graph TD; + pos-navigation-bar --> pos-make-findable + pos-make-findable --> pos-resource + pos-make-findable --> pos-label + pos-resource --> ion-progress-bar + pos-navigation --> pos-navigation-bar + style pos-navigation-bar fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/docs/elements/components/pos-navigation/readme.md b/docs/elements/components/pos-navigation/readme.md new file mode 100644 index 00000000..e2652cb9 --- /dev/null +++ b/docs/elements/components/pos-navigation/readme.md @@ -0,0 +1,49 @@ + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| -------- | --------- | ----------------------------------- | -------- | ------- | +| `uri` | `uri` | Initial value of the navigation bar | `string` | `''` | + + +## Events + +| Event | Description | Type | +| ------------- | ----------- | ------------------ | +| `pod-os:init` | | `CustomEvent` | +| `pod-os:link` | | `CustomEvent` | + + +## Dependencies + +### Used by + + - [pos-app-browser](../../apps/pos-app-browser) + +### Depends on + +- [pos-navigation-bar](bar) +- [pos-rich-link](../pos-rich-link) + +### Graph +```mermaid +graph TD; + pos-navigation --> pos-navigation-bar + pos-navigation --> pos-rich-link + pos-navigation-bar --> pos-make-findable + pos-make-findable --> pos-resource + pos-make-findable --> pos-label + pos-resource --> ion-progress-bar + pos-rich-link --> pos-resource + pos-rich-link --> pos-label + pos-rich-link --> pos-description + pos-app-browser --> pos-navigation + style pos-navigation fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/docs/elements/components/pos-rich-link/readme.md b/docs/elements/components/pos-rich-link/readme.md index b9563d04..289a7d6a 100644 --- a/docs/elements/components/pos-rich-link/readme.md +++ b/docs/elements/components/pos-rich-link/readme.md @@ -24,7 +24,7 @@ ### Used by - [pos-example-resources](../../apps/pos-app-dashboard/pos-example-resources) - - [pos-navigation-bar](../pos-navigation-bar) + - [pos-navigation](../pos-navigation) - [pos-relations](../pos-relations) - [pos-reverse-relations](../pos-reverse-relations) - [pos-subjects](../pos-subjects) @@ -43,7 +43,7 @@ graph TD; pos-rich-link --> pos-description pos-resource --> ion-progress-bar pos-example-resources --> pos-rich-link - pos-navigation-bar --> pos-rich-link + pos-navigation --> pos-rich-link pos-relations --> pos-rich-link pos-reverse-relations --> pos-rich-link pos-subjects --> pos-rich-link diff --git a/elements/CHANGELOG.md b/elements/CHANGELOG.md index 1396b84d..5e068933 100644 --- a/elements/CHANGELOG.md +++ b/elements/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### ⚠ BREAKING CHANGES + +- [pos-navigation](../docs/elements/components/pos-navigation) + - `pos-navigation-bar` has been renamed to `pos-navigation` + - `pos-navigation` is now a more complex navigation widget, not just an input field + ### Fixed - [pos-app-browser](../docs/elements/apps/pos-app-browser): prevent error message flashing up while uri is unset on hard refresh diff --git a/elements/src/apps/pos-app-browser/pos-app-browser.css b/elements/src/apps/pos-app-browser/pos-app-browser.css index 5c1d94ab..e624602f 100644 --- a/elements/src/apps/pos-app-browser/pos-app-browser.css +++ b/elements/src/apps/pos-app-browser/pos-app-browser.css @@ -6,14 +6,17 @@ pos-router { height: 100%; } -pos-navigation-bar { +pos-navigation { max-width: var(--width-lg); margin: 0; + min-width: var(--size-32); + flex-shrink: 1; /* Ensure navigation can shrink */ } pos-add-new-thing, pos-login { - flex: 0 1 auto; /* Behält die Breite des Inhalts bei */ + flex: 0 1 auto; + flex-shrink: 0; /* Ensure those items don't shrink */ } header, @@ -50,8 +53,8 @@ footer { } header { - flex-wrap: wrap; - padding: 0 var(--size-8); + flex-wrap: nowrap; + padding: var(--size-1) var(--size-8); } main { @@ -62,17 +65,7 @@ main { @media (max-width: 640px) { header { - padding: 0 var(--size-1) var(--size-1); + padding: var(--size-1); justify-content: space-between; } - - pos-navigation-bar { - flex-basis: 100%; - order: 0; - } - - pos-add-new-thing, - pos-login { - order: 1; - } } diff --git a/elements/src/apps/pos-app-browser/pos-app-browser.spec.tsx b/elements/src/apps/pos-app-browser/pos-app-browser.spec.tsx index 35baf501..38b83bdf 100644 --- a/elements/src/apps/pos-app-browser/pos-app-browser.spec.tsx +++ b/elements/src/apps/pos-app-browser/pos-app-browser.spec.tsx @@ -33,9 +33,9 @@ describe('pos-app-browser', () => { await page.waitForChanges(); const main = getByRole(page.root, 'banner'); - const navigationBar = main.querySelector('pos-navigation-bar'); + const navigation = main.querySelector('pos-navigation'); - expect(navigationBar).toEqualAttribute('uri', ''); + expect(navigation).toEqualAttribute('uri', ''); }); it('shows uri in navigation bar, if visiting other internal pages', async () => { @@ -49,9 +49,9 @@ describe('pos-app-browser', () => { await page.waitForChanges(); const main = getByRole(page.root, 'banner'); - const navigationBar = main.querySelector('pos-navigation-bar'); + const navigation = main.querySelector('pos-navigation'); - expect(navigationBar).toEqualAttribute('uri', 'pod-os:other'); + expect(navigation).toEqualAttribute('uri', 'pod-os:other'); }); it('shows uri in navigation bar, if visiting http(s) URIs', async () => { @@ -65,9 +65,9 @@ describe('pos-app-browser', () => { await page.waitForChanges(); const main = getByRole(page.root, 'banner'); - const navigationBar = main.querySelector('pos-navigation-bar'); + const navigation = main.querySelector('pos-navigation'); - expect(navigationBar).toEqualAttribute('uri', 'https://resource.test'); + expect(navigation).toEqualAttribute('uri', 'https://resource.test'); }); it('uses type router for http(s) URIs ', async () => { diff --git a/elements/src/apps/pos-app-browser/pos-app-browser.tsx b/elements/src/apps/pos-app-browser/pos-app-browser.tsx index 4bcc0f9f..5e734f73 100644 --- a/elements/src/apps/pos-app-browser/pos-app-browser.tsx +++ b/elements/src/apps/pos-app-browser/pos-app-browser.tsx @@ -25,7 +25,7 @@ export class PosAppBrowser { (this.uri = e.detail)}>
- +
{this.mainContent()}
diff --git a/elements/src/components.d.ts b/elements/src/components.d.ts index a2885085..7f5bda7d 100644 --- a/elements/src/components.d.ts +++ b/elements/src/components.d.ts @@ -5,6 +5,8 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; +import { Thing } from "@pod-os/core"; +export { Thing } from "@pod-os/core"; export namespace Components { interface PosAddLiteralValue { } @@ -95,9 +97,16 @@ export namespace Components { interface PosMakeFindable { "uri": string; } - interface PosNavigationBar { + interface PosNavigation { + /** + * Initial value of the navigation bar + */ "uri": string; } + interface PosNavigationBar { + "current"?: Thing; + "searchIndexReady": boolean; + } interface PosNewThingForm { "referenceUri": string; } @@ -213,6 +222,10 @@ export interface PosMakeFindableCustomEvent extends CustomEvent { detail: T; target: HTMLPosMakeFindableElement; } +export interface PosNavigationCustomEvent extends CustomEvent { + detail: T; + target: HTMLPosNavigationElement; +} export interface PosNavigationBarCustomEvent extends CustomEvent { detail: T; target: HTMLPosNavigationBarElement; @@ -610,10 +623,27 @@ declare global { prototype: HTMLPosMakeFindableElement; new (): HTMLPosMakeFindableElement; }; - interface HTMLPosNavigationBarElementEventMap { + interface HTMLPosNavigationElementEventMap { "pod-os:init": any; "pod-os:link": any; } + interface HTMLPosNavigationElement extends Components.PosNavigation, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLPosNavigationElement, ev: PosNavigationCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLPosNavigationElement, ev: PosNavigationCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLPosNavigationElement: { + prototype: HTMLPosNavigationElement; + new (): HTMLPosNavigationElement; + }; + interface HTMLPosNavigationBarElementEventMap { + "pod-os:navigate": any; + } interface HTMLPosNavigationBarElement extends Components.PosNavigationBar, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLPosNavigationBarElement, ev: PosNavigationBarCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; @@ -881,6 +911,7 @@ declare global { "pos-login": HTMLPosLoginElement; "pos-login-form": HTMLPosLoginFormElement; "pos-make-findable": HTMLPosMakeFindableElement; + "pos-navigation": HTMLPosNavigationElement; "pos-navigation-bar": HTMLPosNavigationBarElement; "pos-new-thing-form": HTMLPosNewThingFormElement; "pos-picture": HTMLPosPictureElement; @@ -1028,11 +1059,19 @@ declare namespace LocalJSX { "onPod-os:search:index-updated"?: (event: PosMakeFindableCustomEvent) => void; "uri": string; } - interface PosNavigationBar { - "onPod-os:init"?: (event: PosNavigationBarCustomEvent) => void; - "onPod-os:link"?: (event: PosNavigationBarCustomEvent) => void; + interface PosNavigation { + "onPod-os:init"?: (event: PosNavigationCustomEvent) => void; + "onPod-os:link"?: (event: PosNavigationCustomEvent) => void; + /** + * Initial value of the navigation bar + */ "uri"?: string; } + interface PosNavigationBar { + "current"?: Thing; + "onPod-os:navigate"?: (event: PosNavigationBarCustomEvent) => void; + "searchIndexReady"?: boolean; + } interface PosNewThingForm { "onPod-os:error"?: (event: PosNewThingFormCustomEvent) => void; "onPod-os:init"?: (event: PosNewThingFormCustomEvent) => void; @@ -1139,6 +1178,7 @@ declare namespace LocalJSX { "pos-login": PosLogin; "pos-login-form": PosLoginForm; "pos-make-findable": PosMakeFindable; + "pos-navigation": PosNavigation; "pos-navigation-bar": PosNavigationBar; "pos-new-thing-form": PosNewThingForm; "pos-picture": PosPicture; @@ -1195,6 +1235,7 @@ declare module "@stencil/core" { "pos-login": LocalJSX.PosLogin & JSXBase.HTMLAttributes; "pos-login-form": LocalJSX.PosLoginForm & JSXBase.HTMLAttributes; "pos-make-findable": LocalJSX.PosMakeFindable & JSXBase.HTMLAttributes; + "pos-navigation": LocalJSX.PosNavigation & JSXBase.HTMLAttributes; "pos-navigation-bar": LocalJSX.PosNavigationBar & JSXBase.HTMLAttributes; "pos-new-thing-form": LocalJSX.PosNewThingForm & JSXBase.HTMLAttributes; "pos-picture": LocalJSX.PosPicture & JSXBase.HTMLAttributes; diff --git a/elements/src/components/pos-make-findable/pos-make-findable.css b/elements/src/components/pos-make-findable/pos-make-findable.css index 009aa7a7..23980892 100644 --- a/elements/src/components/pos-make-findable/pos-make-findable.css +++ b/elements/src/components/pos-make-findable/pos-make-findable.css @@ -12,7 +12,7 @@ button.main { width: var(--size-8); align-items: center; justify-content: center; - border-radius: var(--radius-xs); + border-radius: var(--radius-md); color: var(--pos-subtle-text-color); border: var(--size-px) dashed var(--pos-subtle-text-color); background-color: var(--pos-background-color); @@ -35,6 +35,9 @@ button.main { transform: scale(0.99); filter: brightness(90%); } + &:focus { + outline: var(--pos-input-focus-outline); + } } .options { diff --git a/elements/src/components/pos-navigation-bar/pos-navigation-bar.css b/elements/src/components/pos-navigation-bar/pos-navigation-bar.css deleted file mode 100644 index 2b15db7b..00000000 --- a/elements/src/components/pos-navigation-bar/pos-navigation-bar.css +++ /dev/null @@ -1,51 +0,0 @@ -.suggestions ol { - border: 1px solid var(--pos-border-color); - display: flex; - flex-direction: column; - position: absolute; - margin: 0; - padding: 0; - z-index: var(--layer-top); - list-style-type: none; - box-shadow: var(--shadow-xl); -} - -.suggestions { - position: relative; - li { - padding: 1rem; - background-color: var(--pos-background-color); - pos-rich-link { - --background-color: inherit; - } - &.selected { - background-color: var(--pos-primary-color); - &:hover { - background-color: var(--pos-primary-color); - } - } - &:hover { - background-color: var(--pos-border-color); - } - } -} - -.suggestions li.selected pos-rich-link { - --label-color: white; - --description-color: var(--pos-border-color); - --uri-color: var(--pos-subtle-text-color); -} - -ion-searchbar { - width: 100%; -} - -form { - display: flex; - flex-direction: row; - align-items: center; -} - -.bar { - flex-grow: 1; -} diff --git a/elements/src/components/pos-navigation-bar/pos-navigation-bar.tsx b/elements/src/components/pos-navigation-bar/pos-navigation-bar.tsx deleted file mode 100644 index 4a7f831c..00000000 --- a/elements/src/components/pos-navigation-bar/pos-navigation-bar.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { PodOS, SearchIndex } from '@pod-os/core'; -import { Component, Event, EventEmitter, h, Listen, Prop, State } from '@stencil/core'; - -import session from '../../store/session'; -import { PodOsAware, PodOsEventEmitter, subscribePodOs } from '../events/PodOsAware'; - -@Component({ - tag: 'pos-navigation-bar', - shadow: true, - styleUrl: 'pos-navigation-bar.css', -}) -export class PosNavigationBar implements PodOsAware { - @State() os: PodOS; - - @Event({ eventName: 'pod-os:init' }) subscribePodOs: PodOsEventEmitter; - @Prop() uri: string = ''; - - @State() value: string = this.uri; - - @Event({ eventName: 'pod-os:link' }) linkEmitter: EventEmitter; - - @State() searchIndex?: SearchIndex = undefined; - - @State() suggestions = []; - - @State() selectedIndex = -1; - - componentWillLoad() { - subscribePodOs(this); - session.onChange('isLoggedIn', async isLoggedIn => { - if (isLoggedIn) { - await this.buildSearchIndex(); - } else { - this.clearSearchIndex(); - } - }); - } - - @Listen('pod-os:search:index-created') - private async buildSearchIndex() { - this.searchIndex = await this.os.buildSearchIndex(session.state.profile); - } - - @Listen('pod-os:search:index-updated') - rebuildSearchIndex() { - this.searchIndex.rebuild(); - } - - private clearSearchIndex() { - this.searchIndex?.clear(); - } - - receivePodOs = async (os: PodOS) => { - this.os = os; - }; - - private onChange(event) { - this.value = event.detail.value; - this.search(); - } - - @Listen('click', { target: 'document' }) - @Listen('pod-os:link') - clearSuggestions() { - this.suggestions = []; - this.selectedIndex = -1; - } - - @Listen('click') - onClickSelf(event) { - event.stopPropagation(); - } - - @Listen('keydown') - handleKeyDown(ev: KeyboardEvent) { - if (ev.key === 'Escape') { - this.clearSuggestions(); - } else if (ev.key === 'ArrowDown') { - ev.preventDefault(); - this.selectedIndex = Math.min(this.selectedIndex + 1, this.suggestions.length - 1); - } else if (ev.key === 'ArrowUp') { - ev.preventDefault(); - this.selectedIndex = Math.max(this.selectedIndex - 1, 0); - } - } - - private search() { - if (this.searchIndex) { - this.suggestions = this.value ? this.searchIndex.search(this.value) : []; - } - } - - private onSubmit(event) { - event.preventDefault(); - if (this.suggestions && this.selectedIndex > -1) { - this.linkEmitter.emit(this.suggestions[this.selectedIndex].ref); - } else { - this.linkEmitter.emit(this.value); - } - } - - render() { - return ( -
this.onSubmit(e)}> - {this.searchIndex && this.uri ? : ''} -
- this.onChange(e)} - onIonInput={e => this.onChange(e)} - /> - {this.suggestions.length > 0 ? ( -
-
    - {this.suggestions.map((it, index) => ( -
  1. - -
  2. - ))} -
-
- ) : null} -
-
- ); - } -} diff --git a/elements/src/components/pos-navigation-bar/pos-navigation-bar.integration.spec.tsx b/elements/src/components/pos-navigation/__test/pos-navigation.integration.spec.tsx similarity index 69% rename from elements/src/components/pos-navigation-bar/pos-navigation-bar.integration.spec.tsx rename to elements/src/components/pos-navigation/__test/pos-navigation.integration.spec.tsx index d10b0cc7..5ca9ff7c 100644 --- a/elements/src/components/pos-navigation-bar/pos-navigation-bar.integration.spec.tsx +++ b/elements/src/components/pos-navigation/__test/pos-navigation.integration.spec.tsx @@ -2,19 +2,20 @@ import { SearchIndex, WebIdProfile } from '@pod-os/core'; import { newSpecPage } from '@stencil/core/testing'; import { fireEvent, getByText } from '@testing-library/dom'; import { when } from 'jest-when'; -import session from '../../store/session'; -import { mockPodOS } from '../../test/mockPodOS'; -import { PosApp } from '../pos-app/pos-app'; -import { PosRichLink } from '../pos-rich-link/pos-rich-link'; -import { PosNavigationBar } from './pos-navigation-bar'; +import session from '../../../store/session'; +import { mockPodOS } from '../../../test/mockPodOS'; +import { PosApp } from '../../pos-app/pos-app'; +import { PosRichLink } from '../../pos-rich-link/pos-rich-link'; +import { PosNavigation } from '../pos-navigation'; +import { typeToSearch } from './typeToSearch'; -describe('pos-navigation-bar', () => { +describe('pos-navigation', () => { it('can search after login', async () => { // given PodOS const os = mockPodOS(); // and it can build a search index - const searchIndex = { search: jest.fn() } as SearchIndex; + const searchIndex = { search: jest.fn() } as unknown as SearchIndex; os.buildSearchIndex.mockResolvedValue(searchIndex); // and a search for "test" gives a result @@ -23,18 +24,18 @@ describe('pos-navigation-bar', () => { .mockReturnValue([ { ref: 'https://result.test', - }, + } as any, ]); // and a user profile can be fetched - const profile = { fake: 'profile of the user' } as WebIdProfile; + const profile = { fake: 'profile of the user' } as unknown as WebIdProfile; os.fetchProfile.mockResolvedValue(profile); // and a page with a navigation bar const page = await newSpecPage({ supportsShadowDom: false, - components: [PosApp, PosNavigationBar, PosRichLink], - html: ``, + components: [PosApp, PosNavigation, PosRichLink], + html: ``, }); // and the user is not logged in yet @@ -50,8 +51,7 @@ describe('pos-navigation-bar', () => { await page.waitForChanges(); // when the user types "test" into the navigation bar - const searchBar = page.root.querySelector('ion-searchbar'); - fireEvent(searchBar, new CustomEvent('ionInput', { detail: { value: 'test' } })); + await typeToSearch(page, 'test'); // then a search is triggered expect(searchIndex.search).toHaveBeenCalledWith('test'); @@ -67,7 +67,7 @@ async function waitUntilLoggedIn() { await new Promise((resolve, reject) => { unsubscribe = session.onChange('isLoggedIn', isLoggedIn => { if (isLoggedIn) { - resolve(); + resolve({}); } else { reject(); } diff --git a/elements/src/components/pos-navigation-bar/pos-navigation-bar.spec.tsx b/elements/src/components/pos-navigation/__test/pos-navigation.spec.tsx similarity index 58% rename from elements/src/components/pos-navigation-bar/pos-navigation-bar.spec.tsx rename to elements/src/components/pos-navigation/__test/pos-navigation.spec.tsx index fdd7b446..efc85007 100644 --- a/elements/src/components/pos-navigation-bar/pos-navigation-bar.spec.tsx +++ b/elements/src/components/pos-navigation/__test/pos-navigation.spec.tsx @@ -1,33 +1,131 @@ import { newSpecPage } from '@stencil/core/testing'; import { fireEvent } from '@testing-library/dom'; -import { mockSessionStore } from '../../test/mockSessionStore'; -import { PosNavigationBar } from './pos-navigation-bar'; -import { pressKey } from '../../test/pressKey'; +import { mockSessionStore } from '../../../test/mockSessionStore'; +import { PosNavigation } from '../pos-navigation'; +import { pressKey } from '../../../test/pressKey'; +import { typeToSearch } from './typeToSearch'; -describe('pos-navigation-bar', () => { - it('renders a search bar within a form', async () => { +describe('pos-navigation', () => { + it('renders navigation bar and search dialog', async () => { const page = await newSpecPage({ - components: [PosNavigationBar], - html: ``, + components: [PosNavigation], + html: ``, + supportsShadowDom: false, }); expect(page.root).toEqualHtml(` - - -
-
- -
-
-
-
`); + + + `); + }); + + it('renders pos-navigation-bar when resource is loaded', async () => { + const page = await newSpecPage({ + components: [PosNavigation], + html: ``, + supportsShadowDom: false, + }); + + const mockResource = { fake: 'resource' }; + page.rootInstance.os = { + store: { + get: jest.fn().mockReturnValue(mockResource), + }, + }; + + await page.waitForChanges(); + + expect(page.root).toEqualHtml(` + + + `); + }); + + it('updates current resource when uri changes', async () => { + const page = await newSpecPage({ + components: [PosNavigation], + html: ``, + supportsShadowDom: false, + }); + + const mockResource = { fake: 'resource' }; + page.rootInstance.os = { + store: { + get: jest.fn().mockReturnValue(mockResource), + }, + }; + + page.rootInstance.uri = 'https://pod.example/resource'; + page.rootInstance.updateResource(mockResource); + + expect(page.rootInstance.resource).toEqual(mockResource); + }); + + it('sets current resource to null when uri is invalid', async () => { + const page = await newSpecPage({ + components: [PosNavigation], + html: ``, + supportsShadowDom: false, + }); + + const mockResource = { fake: 'resource' }; + const get = jest.fn(); + get.mockImplementation(() => { + throw new Error('Invalid URI'); + }); + page.rootInstance.os = { + store: { + get, + }, + }; + + page.rootInstance.uri = 'irrelevant, since store mock throws error'; + page.rootInstance.updateResource(mockResource); + + expect(page.rootInstance.resource).toEqual(null); + }); + + it('opens the dialog when navigate event is emitted', async () => { + // given a page with a navigation + const page = await newSpecPage({ + supportsShadowDom: false, + components: [PosNavigation], + html: ``, + }); + + const dialog = page.root.querySelector('dialog'); + dialog.show = jest.fn(); + + // when a "navigate" event is emitted + fireEvent(page.root, new CustomEvent('pod-os:navigate')); + + // then the dialog should be shown + expect(dialog.show).toHaveBeenCalled(); }); it('navigates to entered URI when form is submitted', async () => { // given a page with a navigation bar const page = await newSpecPage({ supportsShadowDom: false, - components: [PosNavigationBar], - html: ``, + components: [PosNavigation], + html: ``, }); // and the page listens for pod-os:link events @@ -35,7 +133,7 @@ describe('pos-navigation-bar', () => { page.root.addEventListener('pod-os:link', linkEventListener); // when the user enters a URI into the searchbar - await type(page, 'https://resource.test/'); + await typeToSearch(page, 'https://resource.test/'); // and then submits the form const form = page.root.querySelector('form'); @@ -60,8 +158,8 @@ describe('pos-navigation-bar', () => { // and a page with a navigation nar page = await newSpecPage({ supportsShadowDom: false, - components: [PosNavigationBar], - html: ``, + components: [PosNavigation], + html: ``, }); // and a fake search index giving 2 results @@ -78,6 +176,9 @@ describe('pos-navigation-bar', () => { rebuild: jest.fn(), }; page.rootInstance.receivePodOs({ + store: { + get: jest.fn().mockReturnValue({ fake: 'resource' }), + }, buildSearchIndex: jest.fn().mockReturnValue(mockSearchIndex), }); @@ -89,37 +190,28 @@ describe('pos-navigation-bar', () => { expect(page.rootInstance.searchIndex).toBeDefined(); }); - it('shows the make-findable button as soon as search index is available', () => { + it('informs navigation bar as soon as search index is available', () => { expect(page.root).toEqualHtml(` - -
- -
- -
-
-
`); - }); - - it('does not show the make-findable button if URI is empty', async () => { - page.root.setAttribute('uri', ''); - await page.waitForChanges(); - expect(page.root).toEqualHtml(` - -
-
- -
-
-
`); + + + `); }); it(' searches for the typed text and shows suggestions', async () => { // when the user enters a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // then the search is triggered - expect(mockSearchIndex.search).toHaveBeenCalledWith('test'); + expect(mockSearchIndex.search).toHaveBeenNthCalledWith(1, 'test'); // and the results are shown as suggestions let suggestions = page.root.querySelector('.suggestions'); @@ -137,15 +229,42 @@ describe('pos-navigation-bar', () => { ); }); + it('searches for the current resource on navigate event', async () => { + const dialog = page.root.querySelector('dialog'); + dialog.show = jest.fn(); + + // when a "navigate" event is emitted + fireEvent( + page.root, + new CustomEvent('pod-os:navigate', { detail: { uri: 'https://pod.example/current-resource' } }), + ); + + // then the dialog should be shown and search for the current resource + expect(dialog.show).toHaveBeenCalled(); + expect(mockSearchIndex.search).toHaveBeenNthCalledWith(1, 'https://pod.example/current-resource'); + }); + + it('does not search on navigate event if current resource is missing', async () => { + const dialog = page.root.querySelector('dialog'); + dialog.show = jest.fn(); + + // when a "navigate" event is emitted + fireEvent(page.root, new CustomEvent('pod-os:navigate', null)); + + // then the dialog should be shown but no search is triggered + expect(dialog.show).toHaveBeenCalled(); + expect(mockSearchIndex.search).not.toHaveBeenCalled(); + }); + it('clears the suggestions when nothing is entered', async () => { // given the user entered a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and suggestions are shown expect(page.root.querySelectorAll('.suggestions li')).toHaveLength(2); // when the input is cleared - await type(page, ''); + await typeToSearch(page, ''); // then no suggestions are shown expect(page.root.querySelector('.suggestions')).toBeNull(); @@ -154,7 +273,7 @@ describe('pos-navigation-bar', () => { describe('keyboard selection', () => { it('selects the first suggestion when pressing key down', async () => { // when the user enters a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and then presses the down arrow key await pressKey(page, 'ArrowDown'); @@ -167,7 +286,7 @@ describe('pos-navigation-bar', () => { it('selects the second suggestion when pressing key down twice', async () => { // when the user enters a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and then presses the down arrow key twice await pressKey(page, 'ArrowDown'); @@ -181,7 +300,7 @@ describe('pos-navigation-bar', () => { it('selects the first suggestion when moving down twice than up', async () => { // when the user enters a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and then presses the down arrow key twice await pressKey(page, 'ArrowDown'); @@ -196,7 +315,7 @@ describe('pos-navigation-bar', () => { it('cannot move further up than top result', async () => { // when the user enters a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and then presses the down arrow key twice await pressKey(page, 'ArrowDown'); @@ -211,7 +330,7 @@ describe('pos-navigation-bar', () => { it('cannot move further down than the last result', async () => { // when the user enters a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and then presses the down arrow key twice await pressKey(page, 'ArrowDown'); @@ -228,13 +347,13 @@ describe('pos-navigation-bar', () => { it('does not clear suggestions when clicked on itself', async () => { // given the user entered a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and suggestions are shown expect(page.root.querySelectorAll('.suggestions li')).toHaveLength(2); // when the user clicks into the search bar - const searchBar = page.root.querySelector('ion-searchbar'); + const searchBar = page.root.querySelector('input'); searchBar.click(); await page.waitForChanges(); @@ -242,9 +361,12 @@ describe('pos-navigation-bar', () => { expect(page.root.querySelectorAll('.suggestions li')).toHaveLength(2); }); - it('clears the suggestions when clicked elsewhere in the document', async () => { + it('closes the suggestions when clicked elsewhere in the document', async () => { + const dialog = page.root.querySelector('dialog'); + dialog.close = jest.fn(); + // given the user entered a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and suggestions are shown expect(page.root.querySelectorAll('.suggestions li')).toHaveLength(2); @@ -253,13 +375,16 @@ describe('pos-navigation-bar', () => { page.doc.click(); await page.waitForChanges(); - // then the suggestions are cleared - expect(page.root.querySelector('.suggestions')).toBeNull(); + // then the dialog is closed + expect(dialog.close).toHaveBeenCalled(); }); - it('clears the suggestions when escape is pressed', async () => { + it('closes the suggestions when escape is pressed', async () => { + const dialog = page.root.querySelector('dialog'); + dialog.close = jest.fn(); + // given the user entered a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and suggestions are shown expect(page.root.querySelectorAll('.suggestions li')).toHaveLength(2); @@ -267,13 +392,16 @@ describe('pos-navigation-bar', () => { // when the user presses escape await pressKey(page, 'Escape'); - // then the suggestions are cleared - expect(page.root.querySelector('.suggestions')).toBeNull(); + // then the dialog is closed + expect(dialog.close).toHaveBeenCalled(); }); it('clears the suggestions when navigating elsewhere', async () => { + const dialog = page.root.querySelector('dialog'); + dialog.close = jest.fn(); + // given the user entered a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and suggestions are shown expect(page.root.querySelectorAll('.suggestions li')).toHaveLength(2); @@ -284,6 +412,9 @@ describe('pos-navigation-bar', () => { // then the suggestions are cleared expect(page.root.querySelector('.suggestions')).toBeNull(); + + // and the dialog is closed + expect(dialog.close).toHaveBeenCalled(); }); it('navigates to selected suggestion when the form is submitted', async () => { @@ -292,7 +423,7 @@ describe('pos-navigation-bar', () => { page.root.addEventListener('pod-os:link', linkEventListener); // when the user enters a text into the searchbar - await type(page, 'test'); + await typeToSearch(page, 'test'); // and then presses the down arrow key to select the first result await pressKey(page, 'ArrowDown'); @@ -350,9 +481,3 @@ describe('pos-navigation-bar', () => { }); }); }); - -async function type(page, text: string) { - const searchBar = page.root.querySelector('ion-searchbar'); - fireEvent(searchBar, new CustomEvent('ionInput', { detail: { value: text } })); - await page.waitForChanges(); -} diff --git a/elements/src/components/pos-navigation/__test/typeToSearch.ts b/elements/src/components/pos-navigation/__test/typeToSearch.ts new file mode 100644 index 00000000..3c73f2f8 --- /dev/null +++ b/elements/src/components/pos-navigation/__test/typeToSearch.ts @@ -0,0 +1,12 @@ +import { fireEvent } from '@testing-library/dom'; + +export async function typeToSearch(page, text: string) { + jest.useFakeTimers(); + const searchBar = page.root.querySelector('input'); + searchBar.value = text; + // @ts-ignore + fireEvent(searchBar, new CustomEvent('change', { target: { value: text } })); + jest.advanceTimersByTime(300); // advance debounce time + jest.useRealTimers(); + await page.waitForChanges(); +} diff --git a/elements/src/components/pos-navigation/bar/pos-navigation-bar.css b/elements/src/components/pos-navigation/bar/pos-navigation-bar.css new file mode 100644 index 00000000..254220e9 --- /dev/null +++ b/elements/src/components/pos-navigation/bar/pos-navigation-bar.css @@ -0,0 +1,27 @@ +section.current { + display: flex; + height: var(--size-8); + flex-grow: 1; + gap: 0; + background-color: var(--pos-input-background-color); + border-radius: var(--radius-md); + width: 100%; + &:focus-within { + outline: var(--pos-input-focus-outline); + } +} + +section.current button { + cursor: pointer; + flex-grow: 1; + background: none; + color: var(--pos-normal-text-color); + outline: none; + border: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + &:focus { + text-decoration: underline; + } +} diff --git a/elements/src/components/pos-navigation/bar/pos-navigation-bar.spec.tsx b/elements/src/components/pos-navigation/bar/pos-navigation-bar.spec.tsx new file mode 100644 index 00000000..8b63ebe3 --- /dev/null +++ b/elements/src/components/pos-navigation/bar/pos-navigation-bar.spec.tsx @@ -0,0 +1,109 @@ +import { h } from '@stencil/core'; +import { newSpecPage } from '@stencil/core/testing'; +import { PosNavigationBar } from './pos-navigation-bar'; +import { screen } from '@testing-library/dom'; +import '@testing-library/jest-dom'; + +describe('pos-navigation-bar', () => { + it('shows the resource label', async () => { + const mockThing = { + uri: 'https://test.pod/resource/1234567890', + label: () => 'Test Label', + }; + + const page = await newSpecPage({ + components: [PosNavigationBar], + template: () => , + supportsShadowDom: false, + }); + + expect(page.root).toEqualHtml(` + +
+ +
+
+ `); + }); + + it('shows nothing if current resource is not set', async () => { + const page = await newSpecPage({ + components: [PosNavigationBar], + template: () => , + supportsShadowDom: false, + }); + + expect(page.root).toEqualHtml(` + +
+ +
+
+ `); + }); + + describe('make findable', () => { + it('shows pos-make-findable when searchIndexReady is true', async () => { + const mockThing = { + uri: 'https://test.pod/resource/1234567890', + label: () => 'Test Label', + }; + + const page = await newSpecPage({ + components: [PosNavigationBar], + template: () => , + supportsShadowDom: false, + }); + + const makeFindable = page.root.querySelector('pos-make-findable'); + expect(makeFindable).not.toBeNull(); + }); + + it('hides pos-make-findable when searchIndexReady is false', async () => { + const mockThing = { + uri: 'https://test.pod/resource/1234567890', + label: () => 'Test Label', + }; + + const page = await newSpecPage({ + components: [PosNavigationBar], + template: () => , + supportsShadowDom: false, + }); + + const makeFindable = page.root.querySelector('pos-make-findable'); + expect(makeFindable).toBeNull(); + }); + }); + + it('emits navigation event with current resource on button click', async () => { + const mockThing = { + uri: 'https://test.pod/resource/1234567890', + label: () => 'Test Label', + }; + + const page = await newSpecPage({ + components: [PosNavigationBar], + template: () => , + supportsShadowDom: false, + }); + + const onNavigate = jest.fn(); + page.root.addEventListener('pod-os:navigate', onNavigate); + + screen.getByRole('button').click(); + + expect(onNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'pod-os:navigate', + detail: expect.objectContaining({ + uri: 'https://test.pod/resource/1234567890', + }), + }), + ); + }); +}); diff --git a/elements/src/components/pos-navigation/bar/pos-navigation-bar.tsx b/elements/src/components/pos-navigation/bar/pos-navigation-bar.tsx new file mode 100644 index 00000000..779ff255 --- /dev/null +++ b/elements/src/components/pos-navigation/bar/pos-navigation-bar.tsx @@ -0,0 +1,31 @@ +import { Component, Event, EventEmitter, h, Prop } from '@stencil/core'; +import { Thing } from '@pod-os/core'; + +@Component({ + tag: 'pos-navigation-bar', + shadow: true, + styleUrl: 'pos-navigation-bar.css', +}) +export class PosNavigationBar { + @Prop() current?: Thing; + @Prop() searchIndexReady: boolean; + + @Event({ eventName: 'pod-os:navigate' }) navigate: EventEmitter; + + private onClick() { + this.navigate.emit(this.current); + } + + render() { + const ariaLabel = this.current ? `${this.current.label()} (Click to search or enter URI)` : 'Search or enter URI'; + + return ( +
+ {this.current && this.searchIndexReady && } + +
+ ); + } +} diff --git a/elements/src/components/pos-navigation/pos-navigation.css b/elements/src/components/pos-navigation/pos-navigation.css new file mode 100644 index 00000000..802090b9 --- /dev/null +++ b/elements/src/components/pos-navigation/pos-navigation.css @@ -0,0 +1,110 @@ +search { + position: relative; +} + +.suggestions ol { + border: 1px solid var(--pos-border-color); + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style-type: none; +} + +.suggestions { + width: 100%; + overflow-y: auto; + max-height: 90dvh; + li { + padding: 1rem; + background-color: var(--pos-background-color); + pos-rich-link { + --background-color: inherit; + } + &.selected { + background-color: var(--pos-primary-color); + &:hover { + background-color: var(--pos-primary-color); + } + } + &:hover { + background-color: var(--pos-border-color); + } + } +} + +.suggestions li.selected pos-rich-link { + --label-color: white; + --description-color: var(--pos-border-color); + --uri-color: var(--pos-subtle-text-color); +} + +dialog { + position: absolute; + margin-top: calc(-1 * var(--size-8)); + padding: 0; + width: 100%; + max-width: 100%; + min-width: 100%; + overflow: hidden; + max-height: 100dvh; + background-color: var(--pos-background-color); + color: var(--pos-normal-text-color); + border: var(--pos-border-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + form { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + align-items: center; + input { + display: flex; + height: var(--size-8); + border-top-left-radius: var(--radius-md); + border-top-right-radius: var(--radius-md); + padding-left: var(--size-2); + width: 100%; + border: none; + outline: none; + color: var(--pos-normal-text-color); + background-color: var(--pos-input-background-color); + box-sizing: border-box; + } + } +} + +dialog[open] { + display: flex; + z-index: var(--layer-top); + animation: slideIn 100ms ease-out; +} + +@media (max-width: 640px) { + search { + position: unset; + } + dialog { + margin-top: var(--size-1); + top: 0; + width: 99dvw; + max-width: unset; + min-width: unset; + form { + input { + height: var(--size-10); + font-size: var(--scale-fluid-2); + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/elements/src/components/pos-navigation/pos-navigation.tsx b/elements/src/components/pos-navigation/pos-navigation.tsx new file mode 100644 index 00000000..3d3bd4a6 --- /dev/null +++ b/elements/src/components/pos-navigation/pos-navigation.tsx @@ -0,0 +1,185 @@ +import { PodOS, SearchIndex, Thing } from '@pod-os/core'; +import { Component, Event, EventEmitter, h, Listen, Prop, State, Watch } from '@stencil/core'; +import { debounceTime, Subject } from 'rxjs'; + +import session from '../../store/session'; +import { PodOsAware, PodOsEventEmitter, subscribePodOs } from '../events/PodOsAware'; + +interface NavigateEvent { + detail: Thing | null; +} + +@Component({ + tag: 'pos-navigation', + shadow: true, + styleUrl: 'pos-navigation.css', +}) +export class PosNavigation implements PodOsAware { + @State() private os: PodOS; + private dialogRef?: HTMLDialogElement; + + @Event({ eventName: 'pod-os:init' }) subscribePodOs: PodOsEventEmitter; + + /** + * Initial value of the navigation bar + */ + @Prop() uri: string = ''; + + /** + * Current value of the input field + */ + @State() private inputValue: string = this.uri; + + @Event({ eventName: 'pod-os:link' }) linkEmitter: EventEmitter; + + @State() searchIndex?: SearchIndex = undefined; + + @State() private suggestions = []; + + @State() private selectedIndex = -1; + + @State() private resource: Thing = null; + + private readonly changeEvents = new Subject(); + private debouncedSearch = null; + + @Watch('uri') + @Watch('os') + updateResource(): void { + try { + this.resource = this.uri ? this.os?.store.get(this.uri) : null; + } catch { + this.resource = null; + } + } + + componentWillLoad() { + subscribePodOs(this); + this.updateResource(); + session.onChange('isLoggedIn', async isLoggedIn => { + if (isLoggedIn) { + await this.buildSearchIndex(); + } else { + this.clearSearchIndex(); + } + }); + this.debouncedSearch = this.changeEvents.pipe(debounceTime(300)).subscribe(() => this.search()); + } + + disconnectedCallback() { + this.debouncedSearch?.unsubscribe(); + } + + @Listen('pod-os:search:index-created') + private async buildSearchIndex() { + this.searchIndex = await this.os.buildSearchIndex(session.state.profile); + } + + @Listen('pod-os:search:index-updated') + rebuildSearchIndex() { + this.searchIndex.rebuild(); + } + + @Listen('pod-os:navigate') + openNavigationDialog(e: NavigateEvent) { + this.resource = e.detail; + if (e.detail) { + this.inputValue = e.detail.uri; + this.search(); + } + this.dialogRef?.show(); + } + + private clearSearchIndex() { + this.searchIndex?.clear(); + } + + receivePodOs = async (os: PodOS) => { + this.os = os; + }; + + private onChange(event) { + this.inputValue = event.target.value; + this.changeEvents.next(); + } + + @Listen('click', { target: 'document' }) + closeDialog() { + this.dialogRef?.close(); + this.selectedIndex = -1; + } + + @Listen('pod-os:link') + clearSuggestions() { + this.suggestions = []; + this.dialogRef?.close(); + this.selectedIndex = -1; + } + + @Listen('click') + onClickSelf(event) { + event.stopPropagation(); + } + + @Listen('keydown') + handleKeyDown(ev: KeyboardEvent) { + if (ev.key === 'Escape') { + this.closeDialog(); + } else if (ev.key === 'ArrowDown') { + ev.preventDefault(); + this.selectedIndex = Math.min(this.selectedIndex + 1, this.suggestions.length - 1); + } else if (ev.key === 'ArrowUp') { + ev.preventDefault(); + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + } + } + + private search() { + if (this.searchIndex) { + this.suggestions = this.inputValue ? this.searchIndex.search(this.inputValue) : []; + } + } + + private onSubmit() { + if (this.suggestions && this.selectedIndex > -1) { + this.linkEmitter.emit(this.suggestions[this.selectedIndex].ref); + } else { + this.linkEmitter.emit(this.inputValue); + } + } + + render() { + return ( + + ); + } +} diff --git a/elements/src/components/pos-router/pos-router.css b/elements/src/components/pos-router/pos-router.css index 765be7b7..c5a0c665 100644 --- a/elements/src/components/pos-router/pos-router.css +++ b/elements/src/components/pos-router/pos-router.css @@ -6,6 +6,6 @@ margin-left: 0.5rem; } -pos-navigation-bar { +pos-navigation { flex-grow: 1; }