Skip to content

Commit

Permalink
Added stimulus & controllers to a landing page
Browse files Browse the repository at this point in the history
  • Loading branch information
owsiakl committed Feb 2, 2024
1 parent dd1e678 commit b820401
Show file tree
Hide file tree
Showing 19 changed files with 1,292 additions and 11 deletions.
1 change: 1 addition & 0 deletions src/web/landing/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.env.local
build/
vendor/
node_modules/
var/
public/assets
3 changes: 3 additions & 0 deletions src/web/landing/assets/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import '@fontsource-variable/cabin/index.min.css';
import 'highlight.js/styles/github-dark.min.css';
import './bootstrap.js';
6 changes: 6 additions & 0 deletions src/web/landing/assets/bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';

const app = startStimulusApp();

// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
4 changes: 4 additions & 0 deletions src/web/landing/assets/controllers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"controllers": [],
"entrypoints": []
}
16 changes: 16 additions & 0 deletions src/web/landing/assets/controllers/syntax_highlight_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {Controller} from '@hotwired/stimulus';
import hljs from 'highlight.js/lib/core';
import php from 'highlight.js/lib/languages/php';

export default class extends Controller
{
static afterLoad(identifier, application)
{
hljs.registerLanguage('php', php);
}

connect()
{
hljs.highlightElement(this.element);
}
}
13 changes: 13 additions & 0 deletions src/web/landing/assets/controllers/tabs_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Controller } from '@hotwired/stimulus';
import {Tabs} from "../services/components/tabs.js";

export default class extends Controller
{
connect()
{
const tabs = new Tabs(this.element);

tabs.onHashChange(window.location.hash)
window.addEventListener('hashchange', event => tabs.onHashChange(event.target.location.hash));
}
}
72 changes: 72 additions & 0 deletions src/web/landing/assets/services/components/tabs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export class Tabs
{
/**
* @type {?HTMLAnchorElement}
*/
#currentLink = null;

/**
* @type {?HTMLAnchorElement}
*/
#currentTopic = null;

/**
* @type {HTMLElement}
*/
#container;

/**
* @param {HTMLElement} container
*/
constructor(container)
{
if (!(container instanceof HTMLElement))
{
throw new Error('Tabs should get html element to work on.');
}

this.#container = container;
}

/**
* @param {string} hash
*/
onHashChange(hash)
{
if (null !== this.#currentLink)
{
this.#currentLink.classList.remove('active');
this.#currentLink = null;
}

if (null !== this.#currentTopic)
{
this.#currentTopic.classList.remove('active');
this.#currentTopic = null;
}

const link = this.#container.querySelector(`a[href="${hash}"]`);
const topicId = link?.getAttribute('data-topic');

if (link)
{
link.classList.add('active');
/**
* @note It would be nice to have behavior:smooth scroll here,
* but looks like chrome engine has a bug that cancels given scroll when another one is created.
* Issue tracker: https://bugs.chromium.org/p/chromium/issues/detail?id=833617.
*/
link.scrollIntoView({behavior: "instant", block: "nearest"});
this.#currentLink = link;
}

if (topicId)
{
const topic = this.#container.querySelector(`a[href="${topicId}"]`);

topic.classList.add('active');
topic.scrollIntoView({behavior: "instant", block: "nearest"});
this.#currentTopic = topic;
}
}
}
4 changes: 4 additions & 0 deletions src/web/landing/assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
@tailwind components;
@tailwind utilities;

body {
font-family: "Cabin Variable", sans-serif;
}

.unset {
inset: unset;
}
11 changes: 11 additions & 0 deletions src/web/landing/assets/test/helpers/jsdom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {JSDOM} from 'jsdom';

beforeEach(() =>
{
const jsdom = new JSDOM(`<!DOCTYPE html><html lang="en"><body></body></html>`);

global.window = jsdom.window;
global.document = window.document;
global.HTMLElement = window.HTMLElement;
global.HTMLDivElement = window.HTMLDivElement;
});
13 changes: 13 additions & 0 deletions src/web/landing/assets/test/jasmine.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"spec_dir": "assets/test",
"spec_files": [
"**/*_test.js"
],
"helpers": [
"helpers/**/*.js"
],
"env": {
"stopSpecOnExpectationFailure": false,
"random": true
}
}
109 changes: 109 additions & 0 deletions src/web/landing/assets/test/services/components/tabs_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {Tabs} from "../../../services/components/tabs.js";

describe("tabs test", () =>
{
/**
* @type {Spy<Func>}
*/
let scrollIntoViewSpy = null;

beforeEach(() =>
{
document.body.insertAdjacentHTML('afterbegin', `
<div data-test-container>
<a href="#topic_1"></a>
<a href="#topic_2"></a>
<a href="#topic_3"></a>
<a href="#topic_without_examples"></a>
<section id="topic_1">
<a href="#example_1" data-topic="#topic_1"></a>
<a href="#example_2" data-topic="#topic_1"></a>
<section id="example_1"></section>
<section id="example_2"></section>
</section>
<section id="topic_2">
<a href="#example_3" data-topic="#topic_2"></a>
<a href="#example_4" data-topic="#topic_2"></a>
<section id="example_3"></section>
<section id="example_4"></section>
</section>
<section id="topic_3">
<a href="#example_5" data-topic="#topic_3"></a>
<a href="#example_6" data-topic="#topic_3"></a>
<section id="example_5"></section>
<section id="example_6"></section>
</section>
<section id="topic_without_examples"></section>
</div>
`);

window.Element.prototype.scrollIntoView = scrollIntoViewSpy = jasmine.createSpy('scrollIntoView');
});

it("should throw an error.", () =>
{
expect(() => new Tabs(null)).toThrowError("Tabs should get html element to work on.")
});

it("should add active class only to a specific topic link.", () =>
{
const tabs = new Tabs(document.querySelector('[data-test-container]'));

tabs.onHashChange('#topic_2');

expect(document.querySelector('[href="#topic_2"]')).toHaveClass('active');
expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1);
});

it("should add active class to a specific example & topic link.", () =>
{
const tabs = new Tabs(document.querySelector('[data-test-container]'));

tabs.onHashChange('#example_5');

expect(document.querySelector('[href="#example_5"]')).toHaveClass('active');
expect(document.querySelector('[href="#topic_3"]')).toHaveClass('active');
expect(scrollIntoViewSpy).toHaveBeenCalledTimes(2);
});

it("should remove active class after changing to non existent hash id.", () =>
{
const tabs = new Tabs(document.querySelector('[data-test-container]'));

tabs.onHashChange('#example_1');

expect(document.querySelector('[href="#example_1"]')).toHaveClass('active');
expect(document.querySelector('[href="#topic_1"]')).toHaveClass('active');

tabs.onHashChange('#non_existing_id');

expect(document.querySelector('[href="#example_1"]')).not.toHaveClass('active');
expect(document.querySelector('[href="#topic_1"]')).not.toHaveClass('active');
});

it("should remove active class and add it to a new example & topic after hash change.", () =>
{
const tabs = new Tabs(document.querySelector('[data-test-container]'));

tabs.onHashChange('#example_2');

expect(document.querySelector('[href="#example_2"]')).toHaveClass('active');
expect(document.querySelector('[href="#topic_1"]')).toHaveClass('active');
expect(document.querySelector('[href="#example_3"]')).not.toHaveClass('active');
expect(document.querySelector('[href="#topic_2"]')).not.toHaveClass('active');

tabs.onHashChange('#example_3');

expect(document.querySelector('[href="#example_2"]')).not.toHaveClass('active');
expect(document.querySelector('[href="#topic_1"]')).not.toHaveClass('active');
expect(document.querySelector('[href="#example_3"]')).toHaveClass('active');
expect(document.querySelector('[href="#topic_2"]')).toHaveClass('active');
});
});
3 changes: 2 additions & 1 deletion src/web/landing/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"symfony/runtime": "^6.4",
"symfony/console": "^6.4",
"symfony/yaml": "^6.4",
"symfony/dotenv": "^6.4"
"symfony/dotenv": "^6.4",
"symfony/stimulus-bundle": "^2.14"
},
"require-dev": {
"symfony/web-profiler-bundle": "^6.4",
Expand Down
71 changes: 70 additions & 1 deletion src/web/landing/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/web/landing/config/bundles.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfonycasts\TailwindBundle\SymfonycastsTailwindBundle::class => ['dev' => true, 'test' => true],
NorbertTech\StaticContentGeneratorBundle\StaticContentGeneratorBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
];
Loading

0 comments on commit b820401

Please sign in to comment.