From 37a8e9aa38c2e2d617315e0be6ead5c1f3ebb174 Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Fri, 13 Feb 2026 16:04:26 -0500 Subject: [PATCH 01/11] Add new page for demo --- src/assets/application.css | 56 ++++++++++++++++++++++++++------------ src/index.html | 8 +++++- src/tabs.html | 36 ++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 src/tabs.html diff --git a/src/assets/application.css b/src/assets/application.css index bfb7b18..7e26694 100644 --- a/src/assets/application.css +++ b/src/assets/application.css @@ -24,24 +24,6 @@ body { letter-spacing: 2px; } -.github-link { - display: inline-block; - margin-top: 20px; - color: #2d3748; - text-decoration: none; - font-size: 18px; - padding: 10px 20px; - border: 2px solid rgba(45, 55, 72, 0.2); - border-radius: 8px; - transition: all 0.3s ease; -} - -.github-link:hover { - background: rgba(45, 55, 72, 0.05); - border-color: rgba(45, 55, 72, 0.4); - transform: translateY(-2px); -} - .theme-controls { display: flex; flex-direction: column; @@ -207,3 +189,41 @@ body { font-size: 18px; font-weight: 600; } + + +/* Navbar */ + +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + gap: 10px; + + .navbar__group { + display: flex; + gap: 10px; + } + + .navbar__link { + display: inline-block; + color: #2d3748; + text-decoration: none; + font-size: 18px; + padding: 10px 20px; + border: 2px solid rgba(45, 55, 72, 0.2); + border-radius: 8px; + transition: all 0.3s ease; + + &:hover { + background: rgba(45, 55, 72, 0.05); + border-color: rgba(45, 55, 72, 0.4); + transform: translateY(-2px); + } + + &.navbar__link--active { + background: rgba(45, 55, 72, 0.15); + border-color: rgba(45, 55, 72, 0.4); + } + } +} diff --git a/src/index.html b/src/index.html index 225a91d..f7add8f 100644 --- a/src/index.html +++ b/src/index.html @@ -11,9 +11,15 @@ +

PDF Viewer

- View on GitHub
diff --git a/src/tabs.html b/src/tabs.html new file mode 100644 index 0000000..9fe3898 --- /dev/null +++ b/src/tabs.html @@ -0,0 +1,36 @@ + + + + + + Spider Web Components - Tabs + + + + + + + + +
+

Tabs

+
+ + + + + + From 866f4ad84711f910abdf8d18d8f5ebf6b692c82d Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Fri, 13 Feb 2026 16:53:42 -0500 Subject: [PATCH 02/11] Add tabs components --- package.json | 1 + src/components/tabs/index.js | 9 +++ src/components/tabs/tabs-panel/tabs-panel.js | 38 ++++++++++++ src/components/tabs/tabs-root/tabs-root.js | 52 ++++++++++++++++ .../tabs/tabs-trigger/tabs-trigger.js | 59 +++++++++++++++++++ src/events/index.js | 1 + src/events/tab-select.js | 5 ++ src/index.js | 1 + src/internal/rolemodel-element.js | 52 ++++++++++++++++ src/tabs.html | 19 +++++- vite.config.js | 2 + 11 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/components/tabs/index.js create mode 100644 src/components/tabs/tabs-panel/tabs-panel.js create mode 100644 src/components/tabs/tabs-root/tabs-root.js create mode 100644 src/components/tabs/tabs-trigger/tabs-trigger.js create mode 100644 src/events/index.js create mode 100644 src/events/tab-select.js create mode 100644 src/internal/rolemodel-element.js diff --git a/package.json b/package.json index 5eff91e..0490ffe 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "import": "./dist/index.js" }, "./dist/components/*": "./dist/components/*", + "./dist/events/*": "./dist/events/*", "./dist/assets/*": "./dist/assets/*" }, "files": [ diff --git a/src/components/tabs/index.js b/src/components/tabs/index.js new file mode 100644 index 0000000..1c6a858 --- /dev/null +++ b/src/components/tabs/index.js @@ -0,0 +1,9 @@ +import RmTabsRoot from './tabs-root/tabs-root.js' +import RmTabsPanel from './tabs-panel/tabs-panel.js' +import RmTabsTrigger from './tabs-trigger/tabs-trigger.js' + +export { + RmTabsRoot, + RmTabsPanel, + RmTabsTrigger +} diff --git a/src/components/tabs/tabs-panel/tabs-panel.js b/src/components/tabs/tabs-panel/tabs-panel.js new file mode 100644 index 0000000..aa67489 --- /dev/null +++ b/src/components/tabs/tabs-panel/tabs-panel.js @@ -0,0 +1,38 @@ +import { html, css } from "lit" +import { ContextConsumer } from "@lit/context" + +import RoleModelElement from "../../../internal/rolemodel-element.js" +import { tabsContext } from "../tabs-root/tabs-root.js" +import { RmTabSelectEvent } from "../../../events/index.js" + +export default class RmTabsPanel extends RoleModelElement { + static properties = { + name: { type: String, reflect: true } + } + + _activeTab = new ContextConsumer(this, { context: tabsContext, subscribe: true }) + + get active() { + return this._activeTab.value === this.name + } + + activate() { + this.dispatchEvent(new RmTabSelectEvent(this.name)) + } + + render() { + return html` +
+ +
+ ` + } + + static styles = css` + :host { + display: block; + } + ` +} + +customElements.define('rm-tabs-panel', RmTabsPanel) diff --git a/src/components/tabs/tabs-root/tabs-root.js b/src/components/tabs/tabs-root/tabs-root.js new file mode 100644 index 0000000..ba0fded --- /dev/null +++ b/src/components/tabs/tabs-root/tabs-root.js @@ -0,0 +1,52 @@ +import { html, css } from "lit" +import { createContext, ContextProvider } from "@lit/context" + +import RoleModelElement from "../../../internal/rolemodel-element" + +const tabsContext = createContext(Symbol("tabs-context")) + +export default class RmTabsRoot extends RoleModelElement { + static properties = { + active: { type: String, reflect: true } + } + + _activeTabProvider = new ContextProvider(this, { + context: tabsContext, + initialValue: "" + }) + + connectedCallback() { + super.connectedCallback() + this.addEventListener("rm-tab-select", this.#handleClick) + } + + disconnectedCallback() { + super.disconnectedCallback() + this.removeEventListener("rm-tab-select", this.#handleClick) + } + + willUpdate(changedProperties) { + if (changedProperties.has("active")) { + this._activeTabProvider.setValue(this.active) + } + } + + #handleClick(event) { + event.stopImmediatePropagation() + this.active = event.detail.name + } + + render() { + return html` ` + } + + static styles = css` + :host { + display: block; + } + ` +} + +export { tabsContext } + +customElements.define('rm-tabs-root', RmTabsRoot) diff --git a/src/components/tabs/tabs-trigger/tabs-trigger.js b/src/components/tabs/tabs-trigger/tabs-trigger.js new file mode 100644 index 0000000..9056c89 --- /dev/null +++ b/src/components/tabs/tabs-trigger/tabs-trigger.js @@ -0,0 +1,59 @@ +import { html, css } from "lit" +import { ContextConsumer } from "@lit/context" + +import RoleModelElement from "../../../internal/rolemodel-element.js" +import { tabsContext } from "../tabs-root/tabs-root.js" +import { RmTabSelectEvent } from "../../../events/index.js" + +export default class RmTabsTrigger extends RoleModelElement { + static properties = { + name: { type: String, reflect: true }, + activeClass: { type: String, reflect: true } + } + + _activeTab = new ContextConsumer(this, { + context: tabsContext, + subscribe: true, + callback: (_value) => this.updateSlottedActiveClass() + }) + + updateSlottedActiveClass() { + const slottedChildren = this._slottedChildren({ flatten: true }) + + if (!slottedChildren || !this.activeClass) return + + slottedChildren.forEach((element) => { + element.classList.toggle(this.activeClass, this.isActive) + }) + } + + get isActive() { + return this._activeTab.value === this.name + } + + setActive() { + this.dispatchEvent(new RmTabSelectEvent(this.name)) + } + + #handleClick(_event) { + this.setActive() + } + + #handleSlotChange(_event) { + this.updateSlottedActiveClass() + } + + render() { + return html` + + ` + } + + static styles = css` + :host { + display: inline-block; + } + ` +} + +customElements.define('rm-tabs-trigger', RmTabsTrigger) diff --git a/src/events/index.js b/src/events/index.js new file mode 100644 index 0000000..da27535 --- /dev/null +++ b/src/events/index.js @@ -0,0 +1 @@ +export { RmTabSelectEvent } from "./tab-select.js" diff --git a/src/events/tab-select.js b/src/events/tab-select.js new file mode 100644 index 0000000..e429151 --- /dev/null +++ b/src/events/tab-select.js @@ -0,0 +1,5 @@ +export class RmTabSelectEvent extends CustomEvent { + constructor(name) { + super("rm-tab-select", { bubbles: true, cancelable: false, composed: true, detail: { name } }) + } +} diff --git a/src/index.js b/src/index.js index ef26040..7004ff9 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,2 @@ export { PDFViewer } from './components/pdf-viewer/index.js' +export { RmTabsRoot, RmTabsPanel, RmTabsTrigger } from './components/tabs/index.js' diff --git a/src/internal/rolemodel-element.js b/src/internal/rolemodel-element.js new file mode 100644 index 0000000..fc7fc63 --- /dev/null +++ b/src/internal/rolemodel-element.js @@ -0,0 +1,52 @@ +import { LitElement } from 'lit' + +export default class RoleModelElement extends LitElement { + constructor() { + super() + this._onConstructor() + this.#initializeDefaults(this.constructor.properties) + this.#initializeElementInternals() + } + + _onConstructor() { + // Callback called after super but before initializing defaults and element internals. + } + + // E.G { flatten: true } + _slottedChildren(options = {}) { + const slot = this.shadowRoot.querySelector("slot") + + return slot?.assignedElements(options) + } + + // Initializing default property values from the static properties object + #initializeDefaults(defaults) { + if (!defaults) return + + for (const [key, value] of Object.entries(defaults)) { + if (this[key] !== undefined) continue + + if (value.default !== undefined) { + // Array and object defaults need to be initialized as a new instance, + // otherwise they will be shared across all instances of the component + if (typeof value.default === "function") { + this[key] = value.default() + } else { + this[key] = value.default + } + } + } + } + + #initializeElementInternals() { + // Use `static formAssociated = true` in your component to opt in to form association. + // If your component is form-associated, you can set the form value like this: + // this.internals.setFormValue(this.value) + + try { + this.internals = this.attachInternals() + } catch { + console.error('Element internals are not supported in your browser. Consider using a polyfill') + } + } +} diff --git a/src/tabs.html b/src/tabs.html index 9fe3898..57973bd 100644 --- a/src/tabs.html +++ b/src/tabs.html @@ -7,7 +7,7 @@ - + @@ -22,6 +22,23 @@

Tabs

+ +
+ + + + + + +
+ + First Tab Content + + + Second Tab Content + +
+ +
+

Basic Tabs

+ +
+ + + + + + +
+ +
+

This is the first tab panel. You can put any content here.

+
+
+ +
+

This is the second tab panel. Try switching tabs!

+
+
+
- - First Tab Content - - - Second Tab Content - - - + +
+

Colorful Tabs

+ +
+ + + + + + + + + +
+ +
+

Red panel content.

+
+
+ +
+

Green panel content.

+
+
+ +
+

Blue panel content.

+
+
+
+
- + +
+

Tabs with Icons

+ +
+ + + + + + + + + +
+ +
+

Welcome to the home tab.

+
+
+ +
+

Starred content goes here.

+
+
+ +
+

Settings panel content.

+
+
+
+
+ From 6b48c89612acd3806c865e2c94539f7f0d9596ef Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Fri, 13 Feb 2026 17:30:50 -0500 Subject: [PATCH 04/11] Add preview and interface details --- src/assets/application.css | 26 ++++++++++++++++++++++++++ src/tabs.html | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/assets/application.css b/src/assets/application.css index 067f48a..a08dfab 100644 --- a/src/assets/application.css +++ b/src/assets/application.css @@ -1,3 +1,10 @@ +/* Box sizing rules */ +*, +*::before, +*::after { + box-sizing: border-box; +} + body { margin: 0; color-scheme: light dark; @@ -9,6 +16,14 @@ body { flex-direction: column; } +section { + inline-size: 100%; + max-inline-size: 768px; + margin-inline: auto; + margin-block-start: 20px; + padding-inline: 20px; +} + .header { text-align: center; padding: 60px 20px 40px; @@ -233,6 +248,7 @@ body { .tabs-demos { display: flex; gap: 2rem; + padding-inline: 20px; justify-content: space-around; align-items: flex-start; flex-wrap: wrap; @@ -312,3 +328,13 @@ body { } } } + +.tabs-preview { + background: #f8f9fa; + padding: 16px; + inline-size: 100%; + border-radius: 4px; + font-size: 14px; + overflow: auto; + margin: 0; +} diff --git a/src/tabs.html b/src/tabs.html index ddc28ad..3d4a6ff 100644 --- a/src/tabs.html +++ b/src/tabs.html @@ -114,5 +114,41 @@

Tabs with Icons

+ +
+
<rm-tabs-root active="first">
+  <div role="tablist">
+    <rm-tabs-trigger name="first" activeClass="active">
+      <button type="button">First</button>
+    </rm-tabs-trigger>
+    <rm-tabs-trigger name="second" activeClass="active">
+      <button  type="button">Second</button>
+    </rm-tabs-trigger>
+  </div>
+  <rm-tabs-panel name="first">
+    <p>This is the <b>first</b> tab panel. You can put any content here.</p>
+  </rm-tabs-panel>
+  <rm-tabs-panel name="second">
+    <p>This is the <b>second</b> tab panel. Try switching tabs!</p>
+  </rm-tabs-panel>
+</rm-tabs-root>
+
+ +
+

Note on usage

+ +

The Tab components do not provide their own stylings. This allows them to provide behavior without imposing a specific look and feel, giving you full control over the appearance.

+
+ +
+

Methods and usage

+
    +
  • rm-tabs-root: set/read active tab with the active attribute/property. Example: tabsRoot.active = "second"
  • +
  • rm-tabs-trigger: call setActive() to activate that trigger's tab. Example: triggerEl.setActive()
  • +
  • rm-tabs-trigger: read isActive to know if trigger is selected. Example: if (triggerEl.isActive) { ... }
  • +
  • rm-tabs-panel: call activate() to make that panel's tab active. Example: panelEl.activate()
  • +
  • rm-tabs-panel: read active to know if panel is currently visible. Example: if (panelEl.active) { ... }
  • +
+
From d246d7de88bec7ea830b51267021094709d7c4a5 Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Mon, 16 Feb 2026 10:23:26 -0500 Subject: [PATCH 05/11] Clean up styles and add css snippet --- src/assets/application.css | 93 ++++++++++++++++++++++++-------------- src/tabs.html | 69 ++++++++++++++++++++++++---- 2 files changed, 120 insertions(+), 42 deletions(-) diff --git a/src/assets/application.css b/src/assets/application.css index a08dfab..4f712c4 100644 --- a/src/assets/application.css +++ b/src/assets/application.css @@ -265,66 +265,91 @@ section { } } +/* Simple example */ + .tabs { + --tabs-color-white: #ffffff; + --tabs-color-black: #000000; + --tabs-color-blue: #2980d9; + + --tabs-font-medium: 16px; + --tabs-font-weight-semi-bold: 600; + + --tabs-spacing-small: 8px; + --tabs-spacing-medium: 16px; + + --tabs-radius-small: 4px; + + --tabs-shadow-small: 0 1px 4px rgb(from var(--tabs-color-black) r g b / 0.03); + .tabs__list { display: flex; - gap: 0.5rem; + gap: var(--tabs-spacing-small); } .tabs__panel { - background: #fff; - border-radius: 0 0 4px 4px; - box-shadow: 0 1px 4px rgba(0,0,0,0.03); - padding: 1.5rem; - min-height: 80px; - - p { - margin: 0; + padding: var(--tabs-spacing-medium); + background-color: var(--tabs-color-white); + border-end-end-radius: var(--tabs-radius-small); + border-end-start-radius: var(--tabs-radius-small); + box-shadow: var(--tabs-shadow-small); + } + + .tabs__btn { + border: none; + background-color: var(--tabs-color-white); + border-start-end-radius: var(--tabs-radius-small); + border-start-start-radius: var(--tabs-radius-small); + color: var(--tabs-color-black); + cursor: pointer; + font-size: var(--tabs-font-medium); + padding-block: var(--tabs-spacing-small); + padding-inline: var(--tabs-spacing-medium); + + &.tabs__btn--active { + background-color: var(--tabs-color-blue); + color: var(--tabs-color-white); + font-weight: var(--tabs-font-weight-semi-bold); } + } +} +/* Additional Examples */ + +.tabs { + --tabs-color-red: #e74c3c; + --tabs-color-green: #27ae60; + + --tabs-border-width: 3px; + + .tabs__panel { &.tabs__panel--red { - border-top: 3px solid #e74c3c; + border-top: var(--tabs-border-width) solid var(--tabs-color-red); } &.tabs__panel--green { - border-top: 3px solid #27ae60; + border-top: var(--tabs-border-width) solid var(--tabs-color-green); } &.tabs__panel--blue { - border-top: 3px solid #2980d9; + border-top: var(--tabs-border-width) solid var(--tabs-color-blue); } } .tabs__btn { - font-family: inherit; - font-size: 1rem; - padding: 0.5rem 1.25rem; - border: none; - border-radius: 4px 4px 0 0; - background: #e9ecef; - color: #333; - cursor: pointer; - transition: background 0.2s, color 0.2s; - - &.tabs__btn--active { - background: #2d72d9; - color: #fff; - font-weight: bold; - } - &.tabs__btn--red { - background: #e74c3c; - color: #fff; + background-color: var(--tabs-color-red); + color: var(--tabs-color-white); } &.tabs__btn--green { - background: #27ae60; - color: #fff; + background-color: var(--tabs-color-green); + color: var(--tabs-color-white); } &.tabs__btn--blue { - background: #2980d9; - color: #fff; + background-color: var(--tabs-color-blue); + color: var(--tabs-color-white); } } } diff --git a/src/tabs.html b/src/tabs.html index 3d4a6ff..af8c988 100644 --- a/src/tabs.html +++ b/src/tabs.html @@ -116,24 +116,77 @@

Tabs with Icons

-
<rm-tabs-root active="first">
-  <div role="tablist">
-    <rm-tabs-trigger name="first" activeClass="active">
-      <button type="button">First</button>
+      
<rm-tabs-root class="tabs" active="first">
+  <div class="tabs__list" role="tablist">
+    <rm-tabs-trigger name="first" activeClass="tabs__btn--active">
+      <button class="tabs__btn" type="button">First</button>
     </rm-tabs-trigger>
-    <rm-tabs-trigger name="second" activeClass="active">
-      <button  type="button">Second</button>
+    <rm-tabs-trigger name="second" activeClass="tabs__btn--active">
+      <button class="tabs__btn" type="button">Second</button>
     </rm-tabs-trigger>
   </div>
   <rm-tabs-panel name="first">
-    <p>This is the <b>first</b> tab panel. You can put any content here.</p>
+    <div class="tabs__panel">
+      <span>This is the <b>first</b> tab panel. You can put any content here.</span>
+    </div>
   </rm-tabs-panel>
   <rm-tabs-panel name="second">
-    <p>This is the <b>second</b> tab panel. Try switching tabs!</p>
+    <div class="tabs__panel">
+      <span>This is the <b>second</b> tab panel. Try switching tabs!</span>
+    </div>
   </rm-tabs-panel>
 </rm-tabs-root>
+
+
.tabs {
+  --tabs-color-white: #ffffff;
+  --tabs-color-black: #000000;
+  --tabs-color-blue: #2980d9;
+
+  --tabs-font-medium: 16px;
+  --tabs-font-weight-semi-bold: 600;
+
+  --tabs-spacing-small: 8px;
+  --tabs-spacing-medium: 16px;
+
+  --tabs-radius-small: 4px;
+
+  --tabs-shadow-small: 0 1px 4px rgb(from var(--tabs-color-black) r g b / 0.03);
+
+  .tabs__list {
+    display: flex;
+    gap: var(--tabs-spacing-small);
+  }
+
+  .tabs__panel {
+    padding: var(--tabs-spacing-medium);
+    background-color: var(--tabs-color-white);
+    border-end-end-radius: var(--tabs-radius-small);
+    border-end-start-radius: var(--tabs-radius-small);
+    box-shadow: var(--tabs-shadow-small);
+  }
+
+  .tabs__btn {
+    border: none;
+    background-color: var(--tabs-color-white);
+    border-start-end-radius: var(--tabs-radius-small);
+    border-start-start-radius: var(--tabs-radius-small);
+    color: var(--tabs-color-black);
+    cursor: pointer;
+    font-size: var(--tabs-font-medium);
+    padding-block: var(--tabs-spacing-small);
+    padding-inline: var(--tabs-spacing-medium);
+
+    &.tabs__btn--active {
+      background-color: var(--tabs-color-blue);
+      color: var(--tabs-color-white);
+      font-weight: var(--tabs-font-weight-semi-bold);
+    }
+  }
+}
+
+

Note on usage

From 8e71ed15f4885269095a54c72c7ac26d582f57a1 Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Mon, 16 Feb 2026 10:34:45 -0500 Subject: [PATCH 06/11] bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0490ffe..e4ccfef 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@rolemodel/spider", "description": "Shared high level web components for RoleModel Software and beyond", "packageManager": "yarn@4.12.0", - "version": "0.0.3", + "version": "0.0.4", "author": "RoleModel Software", "license": "MIT", "type": "module", From 1c918161a69dd21ec59c98aa245ddba515a70de3 Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Mon, 16 Feb 2026 10:52:00 -0500 Subject: [PATCH 07/11] Update PDF components to use new parent component --- src/components/pdf-viewer/pdf-viewer-component.js | 5 +++-- src/components/pdf-viewer/pdf-viewer.js | 7 ++++--- test/setup.js | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/components/pdf-viewer/pdf-viewer-component.js b/src/components/pdf-viewer/pdf-viewer-component.js index 89d5cb0..9d48f87 100644 --- a/src/components/pdf-viewer/pdf-viewer-component.js +++ b/src/components/pdf-viewer/pdf-viewer-component.js @@ -1,8 +1,9 @@ -import { LitElement } from 'lit' import { ContextConsumer } from '@lit/context' import { pdfContext } from './pdf-context.js' -export class PDFViewerComponent extends LitElement { +import RoleModelElement from "../../internal/rolemodel-element.js" + +export class PDFViewerComponent extends RoleModelElement { static get styles() { return [] } diff --git a/src/components/pdf-viewer/pdf-viewer.js b/src/components/pdf-viewer/pdf-viewer.js index f0df139..f29b119 100644 --- a/src/components/pdf-viewer/pdf-viewer.js +++ b/src/components/pdf-viewer/pdf-viewer.js @@ -1,4 +1,4 @@ -import { LitElement, html } from 'lit' +import { html } from 'lit' import { ContextProvider } from '@lit/context' import * as pdfjsLib from 'pdfjs-dist' import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?url' @@ -10,9 +10,11 @@ import './toolbar/pdf-toolbar.js' import './sidebar/pdf-sidebar.js' import './canvas/pdf-canvas.js' +import RoleModelElement from '../../internal/rolemodel-element.js' + pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker -export default class PDFViewer extends LitElement { +export default class PDFViewer extends RoleModelElement { static get properties() { return { src: { type: String }, @@ -547,4 +549,3 @@ export default class PDFViewer extends LitElement { } customElements.define('rm-pdf-viewer', PDFViewer) - diff --git a/test/setup.js b/test/setup.js index 5385fb1..d2b2e88 100644 --- a/test/setup.js +++ b/test/setup.js @@ -15,6 +15,20 @@ global.IntersectionObserver = vi.fn().mockImplementation(() => ({ disconnect: vi.fn(), })) +// Test Shim for attachInternals, which is not supported in the test environment. +// This allows us to test components that use attachInternals without throwing errors. +if (!HTMLElement.prototype.attachInternals) { + Object.defineProperty(HTMLElement.prototype, 'attachInternals', { + configurable: true, + value: vi.fn(() => ({ + setFormValue: vi.fn(), + setValidity: vi.fn(), + checkValidity: vi.fn(() => true), + reportValidity: vi.fn(() => true), + })), + }) +} + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({ fillRect: vi.fn(), clearRect: vi.fn(), From d125822228cdf723dc81ac8a5889e8acf0986725 Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Mon, 16 Feb 2026 11:08:50 -0500 Subject: [PATCH 08/11] Add tests for tabs --- test/components/tabs-panel.test.js | 75 +++++++++++++++++++++++ test/components/tabs-root.test.js | 74 ++++++++++++++++++++++ test/components/tabs-trigger.test.js | 72 ++++++++++++++++++++++ test/helpers/tabs-test-utils.js | 53 ++++++++++++++++ test/integration/tabs-integration.test.js | 57 +++++++++++++++++ 5 files changed, 331 insertions(+) create mode 100644 test/components/tabs-panel.test.js create mode 100644 test/components/tabs-root.test.js create mode 100644 test/components/tabs-trigger.test.js create mode 100644 test/helpers/tabs-test-utils.js create mode 100644 test/integration/tabs-integration.test.js diff --git a/test/components/tabs-panel.test.js b/test/components/tabs-panel.test.js new file mode 100644 index 0000000..bf325ce --- /dev/null +++ b/test/components/tabs-panel.test.js @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import '../../src/components/tabs/index.js' + +async function createPanelFixture({ active = 'first', panelName = 'first' } = {}) { + const root = document.createElement('rm-tabs-root') + root.active = active + root.innerHTML = ` + +
+ ${panelName} panel content +
+
+ ` + + document.body.appendChild(root) + + await root.updateComplete + + const panel = root.querySelector('rm-tabs-panel') + + await panel.updateComplete + + return { root, panel } +} + +async function waitForUpdates(...elements) { + await Promise.all(elements.map(element => element.updateComplete)) +} + +describe('RmTabsPanel', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('active reflects rm-tabs-root active tab', async () => { + const { root, panel } = await createPanelFixture({ active: 'first', panelName: 'first' }) + + expect(panel.active).toBe(true) + + root.active = 'second' + await waitForUpdates(root, panel) + + expect(panel.active).toBe(false) + }) + + it('activate dispatches rm-tab-select and updates active panel', async () => { + const { root, panel } = await createPanelFixture({ active: 'second', panelName: 'first' }) + const listener = vi.fn() + + panel.addEventListener('rm-tab-select', listener) + panel.activate() + + await waitForUpdates(root, panel) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener.mock.calls[0][0].detail).toEqual({ name: 'first' }) + expect(root.active).toBe('first') + expect(panel.active).toBe(true) + }) + + it('updates hidden and aria-hidden on tabpanel based on active state', async () => { + const { root, panel } = await createPanelFixture({ active: 'first', panelName: 'first' }) + + const panelContainer = panel.shadowRoot.querySelector('[role="tabpanel"]') + + expect(panelContainer.hidden).toBe(false) + expect(panelContainer.getAttribute('aria-hidden')).toBe('false') + + root.active = 'second' + await waitForUpdates(root, panel) + + expect(panelContainer.hidden).toBe(true) + expect(panelContainer.getAttribute('aria-hidden')).toBe('true') + }) +}) diff --git a/test/components/tabs-root.test.js b/test/components/tabs-root.test.js new file mode 100644 index 0000000..4e48c5e --- /dev/null +++ b/test/components/tabs-root.test.js @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import '../../src/components/tabs/index.js' + +async function createRootFixture({ active = 'first', tabName = 'first' } = {}) { + const root = document.createElement('rm-tabs-root') + root.active = active + root.innerHTML = ` + + + + +
+ ${tabName} panel content +
+
+ ` + + document.body.appendChild(root) + + await root.updateComplete + + const trigger = root.querySelector('rm-tabs-trigger') + const panel = root.querySelector('rm-tabs-panel') + + await Promise.all([trigger.updateComplete, panel.updateComplete]) + + return { root, trigger, panel } +} + +async function waitForUpdates(...elements) { + await Promise.all(elements.map(element => element.updateComplete)) +} + +describe('RmTabsRoot', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('renders slotted tab triggers and panels', async () => { + const { root } = await createRootFixture({ active: 'first', tabName: 'first' }) + + expect(root.querySelectorAll('rm-tabs-trigger')).toHaveLength(1) + expect(root.querySelectorAll('rm-tabs-panel')).toHaveLength(1) + }) + + it('updates active when rm-tab-select bubbles from descendants', async () => { + const { root, trigger } = await createRootFixture({ active: 'first', tabName: 'first' }) + + trigger.dispatchEvent( + new CustomEvent('rm-tab-select', { + bubbles: true, + composed: true, + detail: { name: 'second' } + }) + ) + + await waitForUpdates(root, trigger) + + expect(root.active).toBe('second') + }) + + it('updates child active state when active changes', async () => { + const { root, trigger, panel } = await createRootFixture({ active: 'first', tabName: 'first' }) + + expect(trigger.isActive).toBe(true) + expect(panel.active).toBe(true) + + root.active = 'second' + await waitForUpdates(root, trigger, panel) + + expect(trigger.isActive).toBe(false) + expect(panel.active).toBe(false) + }) +}) diff --git a/test/components/tabs-trigger.test.js b/test/components/tabs-trigger.test.js new file mode 100644 index 0000000..0c1eed6 --- /dev/null +++ b/test/components/tabs-trigger.test.js @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import '../../src/components/tabs/index.js' + +async function createTriggerFixture({ active = 'first', triggerName = 'first' } = {}) { + const root = document.createElement('rm-tabs-root') + root.active = active + root.innerHTML = ` + + + + ` + + document.body.appendChild(root) + + await root.updateComplete + + const trigger = root.querySelector('rm-tabs-trigger') + const button = trigger.querySelector('button') + + await trigger.updateComplete + + return { root, trigger, button } +} + +async function waitForUpdates(...elements) { + await Promise.all(elements.map(element => element.updateComplete)) +} + +describe('RmTabsTrigger', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('setActive dispatches rm-tab-select with expected detail', async () => { + const { root, trigger } = await createTriggerFixture({ active: 'second', triggerName: 'first' }) + const listener = vi.fn() + + trigger.addEventListener('rm-tab-select', listener) + trigger.setActive() + + await waitForUpdates(root, trigger) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener.mock.calls[0][0].detail).toEqual({ name: 'first' }) + expect(root.active).toBe('first') + }) + + it('isActive reflects rm-tabs-root active tab', async () => { + const { root, trigger } = await createTriggerFixture({ active: 'first', triggerName: 'first' }) + + expect(trigger.isActive).toBe(true) + + root.active = 'second' + await waitForUpdates(root, trigger) + + expect(trigger.isActive).toBe(false) + }) + + it('toggles activeClass and aria-selected when active tab changes', async () => { + const { root, trigger, button } = await createTriggerFixture({ active: 'first', triggerName: 'first' }) + const triggerSlot = trigger.shadowRoot.querySelector('slot') + + expect(button.classList.contains('tabs__btn--active')).toBe(true) + expect(triggerSlot.getAttribute('aria-selected')).toBe('true') + + root.active = 'second' + await waitForUpdates(root, trigger) + + expect(button.classList.contains('tabs__btn--active')).toBe(false) + expect(triggerSlot.getAttribute('aria-selected')).toBe('false') + }) +}) diff --git a/test/helpers/tabs-test-utils.js b/test/helpers/tabs-test-utils.js new file mode 100644 index 0000000..4c62734 --- /dev/null +++ b/test/helpers/tabs-test-utils.js @@ -0,0 +1,53 @@ +import '../../src/components/tabs/index.js' + +export async function createTabsFixture({ active = 'first' } = {}) { + const root = document.createElement('rm-tabs-root') + root.active = active + root.className = 'tabs' + root.innerHTML = ` +
+ + + + + + +
+ +
+ First panel content +
+
+ +
+ Second panel content +
+
+ ` + + document.body.appendChild(root) + + await root.updateComplete + + const triggers = [...root.querySelectorAll('rm-tabs-trigger')] + const panels = [...root.querySelectorAll('rm-tabs-panel')] + + await Promise.all([ + ...triggers.map(trigger => trigger.updateComplete), + ...panels.map(panel => panel.updateComplete) + ]) + + return { + root, + firstTrigger: triggers[0], + secondTrigger: triggers[1], + firstPanel: panels[0], + secondPanel: panels[1], + firstButton: triggers[0].querySelector('button'), + secondButton: triggers[1].querySelector('button') + } +} + +export async function waitForUpdates(...elements) { + await Promise.all(elements.map(element => element.updateComplete)) +} diff --git a/test/integration/tabs-integration.test.js b/test/integration/tabs-integration.test.js new file mode 100644 index 0000000..cbf9ce6 --- /dev/null +++ b/test/integration/tabs-integration.test.js @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createTabsFixture, waitForUpdates } from '../helpers/tabs-test-utils.js' + +describe('Tabs Integration Tests', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('renders tab system with expected initial active states', async () => { + const { root, firstTrigger, secondTrigger, firstPanel, secondPanel } = await createTabsFixture({ active: 'first' }) + + expect(root.querySelectorAll('rm-tabs-trigger')).toHaveLength(2) + expect(root.querySelectorAll('rm-tabs-panel')).toHaveLength(2) + expect(firstTrigger.isActive).toBe(true) + expect(secondTrigger.isActive).toBe(false) + expect(firstPanel.active).toBe(true) + expect(secondPanel.active).toBe(false) + }) + + it('updates trigger classes and panel visibility when trigger selects another tab', async () => { + const { root, firstTrigger, secondTrigger, firstButton, secondButton, firstPanel, secondPanel } = await createTabsFixture({ active: 'first' }) + + secondTrigger.setActive() + await waitForUpdates(root, firstTrigger, secondTrigger, firstPanel, secondPanel) + + const firstPanelContainer = firstPanel.shadowRoot.querySelector('[role="tabpanel"]') + const secondPanelContainer = secondPanel.shadowRoot.querySelector('[role="tabpanel"]') + + expect(root.active).toBe('second') + expect(firstButton.classList.contains('tabs__btn--active')).toBe(false) + expect(secondButton.classList.contains('tabs__btn--active')).toBe(true) + expect(firstPanelContainer.hidden).toBe(true) + expect(secondPanelContainer.hidden).toBe(false) + }) + + it('keeps accessibility attributes in sync across triggers and panels', async () => { + const { root, firstTrigger, secondTrigger, firstPanel, secondPanel } = await createTabsFixture({ active: 'first' }) + + const firstTriggerSlot = firstTrigger.shadowRoot.querySelector('slot') + const secondTriggerSlot = secondTrigger.shadowRoot.querySelector('slot') + const firstPanelContainer = firstPanel.shadowRoot.querySelector('[role="tabpanel"]') + const secondPanelContainer = secondPanel.shadowRoot.querySelector('[role="tabpanel"]') + + expect(firstTriggerSlot.getAttribute('aria-selected')).toBe('true') + expect(secondTriggerSlot.getAttribute('aria-selected')).toBe('false') + expect(firstPanelContainer.getAttribute('aria-hidden')).toBe('false') + expect(secondPanelContainer.getAttribute('aria-hidden')).toBe('true') + + secondPanel.activate() + await waitForUpdates(root, firstTrigger, secondTrigger, firstPanel, secondPanel) + + expect(firstTriggerSlot.getAttribute('aria-selected')).toBe('false') + expect(secondTriggerSlot.getAttribute('aria-selected')).toBe('true') + expect(firstPanelContainer.getAttribute('aria-hidden')).toBe('true') + expect(secondPanelContainer.getAttribute('aria-hidden')).toBe('false') + }) +}) From 416238fe81cd9eb7fc162c3061907b78e3cf834b Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Mon, 16 Feb 2026 12:00:37 -0500 Subject: [PATCH 09/11] Add blurb about usage with Optics --- src/tabs.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tabs.html b/src/tabs.html index af8c988..2d8f5cc 100644 --- a/src/tabs.html +++ b/src/tabs.html @@ -193,6 +193,11 @@

Note on usage

The Tab components do not provide their own stylings. This allows them to provide behavior without imposing a specific look and feel, giving you full control over the appearance.

+
+

Usage with Optics

+

These Tab components can be paired with Optics to create a visually consistent and accessible tab interface. Using the Tab and Card components from Optics will give you simple default styling, but you have the freedom to use any component or custom style them however you like.

+
+

Methods and usage

    From 6f757deeb70201fd3ff40455dcef7508e10eea15 Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Mon, 16 Feb 2026 13:15:50 -0500 Subject: [PATCH 10/11] Rename setActive to activate --- src/components/tabs/tabs-trigger/tabs-trigger.js | 4 ++-- src/tabs.html | 2 +- test/components/tabs-trigger.test.js | 4 ++-- test/integration/tabs-integration.test.js | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/tabs/tabs-trigger/tabs-trigger.js b/src/components/tabs/tabs-trigger/tabs-trigger.js index 9056c89..e48bf59 100644 --- a/src/components/tabs/tabs-trigger/tabs-trigger.js +++ b/src/components/tabs/tabs-trigger/tabs-trigger.js @@ -31,12 +31,12 @@ export default class RmTabsTrigger extends RoleModelElement { return this._activeTab.value === this.name } - setActive() { + activate() { this.dispatchEvent(new RmTabSelectEvent(this.name)) } #handleClick(_event) { - this.setActive() + this.activate() } #handleSlotChange(_event) { diff --git a/src/tabs.html b/src/tabs.html index 2d8f5cc..c188bd8 100644 --- a/src/tabs.html +++ b/src/tabs.html @@ -202,7 +202,7 @@

    Usage with Optics

    Methods and usage

    • rm-tabs-root: set/read active tab with the active attribute/property. Example: tabsRoot.active = "second"
    • -
    • rm-tabs-trigger: call setActive() to activate that trigger's tab. Example: triggerEl.setActive()
    • +
    • rm-tabs-trigger: call activate() to activate that trigger's tab. Example: triggerEl.activate()
    • rm-tabs-trigger: read isActive to know if trigger is selected. Example: if (triggerEl.isActive) { ... }
    • rm-tabs-panel: call activate() to make that panel's tab active. Example: panelEl.activate()
    • rm-tabs-panel: read active to know if panel is currently visible. Example: if (panelEl.active) { ... }
    • diff --git a/test/components/tabs-trigger.test.js b/test/components/tabs-trigger.test.js index 0c1eed6..f2fc26e 100644 --- a/test/components/tabs-trigger.test.js +++ b/test/components/tabs-trigger.test.js @@ -31,12 +31,12 @@ describe('RmTabsTrigger', () => { document.body.innerHTML = '' }) - it('setActive dispatches rm-tab-select with expected detail', async () => { + it('activate dispatches rm-tab-select with expected detail', async () => { const { root, trigger } = await createTriggerFixture({ active: 'second', triggerName: 'first' }) const listener = vi.fn() trigger.addEventListener('rm-tab-select', listener) - trigger.setActive() + trigger.activate() await waitForUpdates(root, trigger) diff --git a/test/integration/tabs-integration.test.js b/test/integration/tabs-integration.test.js index cbf9ce6..a318d60 100644 --- a/test/integration/tabs-integration.test.js +++ b/test/integration/tabs-integration.test.js @@ -20,7 +20,7 @@ describe('Tabs Integration Tests', () => { it('updates trigger classes and panel visibility when trigger selects another tab', async () => { const { root, firstTrigger, secondTrigger, firstButton, secondButton, firstPanel, secondPanel } = await createTabsFixture({ active: 'first' }) - secondTrigger.setActive() + secondTrigger.activate() await waitForUpdates(root, firstTrigger, secondTrigger, firstPanel, secondPanel) const firstPanelContainer = firstPanel.shadowRoot.querySelector('[role="tabpanel"]') From ef5b310a2b88016feaa16337a7c66ccc69ac2e08 Mon Sep 17 00:00:00 2001 From: Jeremy Walton Date: Mon, 16 Feb 2026 13:16:02 -0500 Subject: [PATCH 11/11] Update readme to detail new tabs and usage --- README.md | 139 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 123 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3ff5dc3..25f634a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ # Spider Web Components -By RoleModel Software -# PDF Viewer +By RoleModel Software -[PDF.js](https://mozilla.github.io/pdf.js) is awesome, but you will notice this paragraph in their setup instructions: -> The viewer is built on the display layer and is the UI for PDF viewer in Firefox and the other browser extensions within the project. It can be a good starting point for building your own viewer. However, we do ask if you plan to embed the viewer in your own site, that it not just be an unmodified version. Please re-skin it or build upon it. +Spider is a set of reusable web components built with Lit. It currently provides: -This component aims to be that skin layer built upon PDF.js packaged in a lovely drop-in web component. +- `rm-pdf-viewer`: reskinned PDF viewer built on [PDF.js](https://mozilla.github.io/pdf.js) +- `rm-tabs-root`, `rm-tabs-trigger`, `rm-tabs-panel`: unstyled behavior-first tabs primitives A customizable PDF viewer web component built on [PDF.js](https://mozilla.github.io/pdf.js). This component provides a reskinned, embeddable PDF viewing experience with text selection, zoom controls, thumbnail navigation, and theme customization. @@ -16,6 +15,34 @@ A customizable PDF viewer web component built on [PDF.js](https://mozilla.github yarn add @rolemodel/spider ``` +## Usage + +Import once to register all components: + +```js +import '@rolemodel/spider' +``` + +Or import specific component modules from the package distribution output. + +## Development + +```bash +yarn dev +yarn test +``` + +## Components + +### PDF Viewer `rm-pdf-viewer` + +[PDF.js](https://mozilla.github.io/pdf.js) is awesome, but you will notice this paragraph in their setup instructions: +> The viewer is built on the display layer and is the UI for PDF viewer in Firefox and the other browser extensions within the project. It can be a good starting point for building your own viewer. However, we do ask if you plan to embed the viewer in your own site, that it not just be an unmodified version. Please re-skin it or build upon it. + +This component aims to be that skin layer built upon PDF.js packaged in a lovely drop-in web component. + +#### Basic usage + ```html @@ -25,7 +52,7 @@ yarn add @rolemodel/spider ``` -### With Custom Close Button +#### With custom close button ```html @@ -35,21 +62,101 @@ yarn add @rolemodel/spider ``` -## Properties +#### Attributes / properties -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `src` | String | `''` | Path to the PDF file | -| `initial-page` | Number | `1` | Initial page to display | -| `close-url` | String | `''` | URL to redirect when close button is clicked | -| `theme-hue` | Number | `217` | Hue value (0-360) for theme color | -| `theme-saturation` | Number | `89` | Saturation value (0-100) for theme color | +| Name | Type | Default | Description | +|------|------|---------|-------------| +| `src` | `string` | `''` | PDF URL/path | +| `open` | `boolean` | `false` | Shows the viewer when present/true | +| `initial-page` | `number` | `1` | Initial page to open | +| `theme-hue` | `number` | `217` | Theme hue (`0-360`) | +| `theme-saturation` | `number` | `89` | Theme saturation (`0-100`) | +| `escape-closes-viewer` | `boolean` | `false` | Closes viewer on `Escape` when search is not open | -## Slots +#### Slot | Slot | Description | |------|-------------| -| `close-button` | Custom close button element | +| `close-button` | Custom close action element rendered in the toolbar | + +#### Public methods + +| Method | Description | +|--------|-------------| +| `loadPDF()` | Loads the PDF from `src` | +| `printPDF()` | Opens browser print flow for the loaded PDF | +| `downloadPDF()` | Downloads the current `src` | +| `fitPDFToScreen()` | Applies calculated fit-to-screen zoom | +| `performSearch(term)` | Searches text across pages | +| `goToNextMatch()` / `goToPreviousMatch()` | Navigates search matches | + +### Tabs components + +Tabs are split into three composable components so behavior is provided without enforcing styling. + +#### `rm-tabs-root` + +Owns the active tab state and coordinates triggers/panels. + +| Name | Type | Description | +|------|------|-------------| +| `active` | `string` | Current active tab name | + +#### `rm-tabs-trigger` + +Declares a selectable tab trigger. + +| Name | Type | Description | +|------|------|-------------| +| `name` | `string` | Tab name this trigger controls | +| `activeClass` | `string` | Class toggled on slotted elements when active | + +| Member | Type | Description | +|--------|------|-------------| +| `isActive` | getter | `true` when this trigger is selected | +| `activate()` | method | Dispatches a tab select event for this trigger | + +#### `rm-tabs-panel` + +Declares content for a tab. + +| Name | Type | Description | +|------|------|-------------| +| `name` | `string` | Tab name this panel belongs to | + +| Member | Type | Description | +|--------|------|-------------| +| `active` | getter | `true` when this panel is visible | +| `activate()` | method | Dispatches a tab select event for this panel | + +#### Tabs event + +| Event | Detail | Description | +|-------|--------|-------------| +| `rm-tab-select` | `{ name: string }` | Emitted by triggers/panels to request active tab changes | + +#### Tabs usage example + +```html + +
      + + + + + + +
      + + + First tab content + + + + Second tab content + +
      +``` ## License