From af7300e586405962c00111f281ad1df8f53921e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elodie=20Labb=C3=A9?= Date: Mon, 16 Feb 2026 12:01:04 +0100 Subject: [PATCH 1/3] Create scrollable bar component --- addon/components/o-s-s/scrollable-bar.hbs | 47 +++++++ addon/components/o-s-s/scrollable-bar.ts | 103 +++++++++++++++ app/components/o-s-s/scrollable-bar.js | 1 + app/styles/organisms/scrollable-bar.less | 120 ++++++++++++++++++ app/styles/oss-components.less | 1 + tests/dummy/app/templates/visual.hbs | 44 +++++++ .../components/o-s-s/scrollable-bar-test.ts | 105 +++++++++++++++ 7 files changed, 421 insertions(+) create mode 100644 addon/components/o-s-s/scrollable-bar.hbs create mode 100644 addon/components/o-s-s/scrollable-bar.ts create mode 100644 app/components/o-s-s/scrollable-bar.js create mode 100644 app/styles/organisms/scrollable-bar.less create mode 100644 tests/integration/components/o-s-s/scrollable-bar-test.ts diff --git a/addon/components/o-s-s/scrollable-bar.hbs b/addon/components/o-s-s/scrollable-bar.hbs new file mode 100644 index 000000000..de807a3d1 --- /dev/null +++ b/addon/components/o-s-s/scrollable-bar.hbs @@ -0,0 +1,47 @@ +
+ {{#if this.isLeftScrollable}} + {{#if (eq this.buttonStyle "icon")}} + + {{else}} + + {{/if}} + {{/if}} + +
+ {{yield (hash setupFn=this.observeIntersection teardownFn=this.unobserveIntersection) to="content"}} +
+ + {{#if this.isRightScrollable}} + {{#if (eq this.buttonStyle "icon")}} + + {{else}} + + {{/if}} + {{/if}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/scrollable-bar.ts b/addon/components/o-s-s/scrollable-bar.ts new file mode 100644 index 000000000..dc572fe99 --- /dev/null +++ b/addon/components/o-s-s/scrollable-bar.ts @@ -0,0 +1,103 @@ +import { action } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +interface OSSScrollableBarComponentSignature { + buttonStyle?: 'button' | 'icon'; +} + +export default class extends Component { + @tracked filterEntries: IntersectionObserverEntry[] = []; + + scrollableBarId: string = guidFor(this); + declare intersectionObserver: IntersectionObserver; + + constructor(owner: unknown, args: OSSScrollableBarComponentSignature) { + super(owner, args); + + this.intersectionObserver = new IntersectionObserver(this.intersectionObserverCallback.bind(this), { + root: null, + rootMargin: '0px', + threshold: [0, 1] + }); + } + + get containerElement(): HTMLElement { + return document.querySelector(`#${this.scrollableBarId} .inner-container`) as HTMLElement; + } + + get scrollWidth(): number { + // May be updated, waiting for product input + return (this.containerElement.clientWidth / 3) * 2; + } + + get isLeftScrollable(): boolean { + if (this.filterEntries.length === 0 || this.getFilterNodes().length === 0) return false; + const entry = this.filterEntries.find((f) => f.target === this.getFilterNodes()[0]); + return entry !== undefined && entry.intersectionRatio < 1; + } + + get isRightScrollable(): boolean { + if (this.filterEntries.length === 0 || this.getFilterNodes().length === 0) return false; + const entry = this.filterEntries.find((f) => f.target === this.getFilterNodes()[this.getFilterNodes().length - 1]); + return entry !== undefined && entry.intersectionRatio < 1; + } + + get buttonStyle(): string { + return this.args.buttonStyle === 'icon' ? 'icon' : 'button'; + } + + get innerContainerStyle(): string { + const baseStyle = this.args.buttonStyle === 'icon' ? 'inner-container-icon' : 'inner-container-btn'; + let innerContainerStyle = 'inner-container'; + if (this.isLeftScrollable) { + innerContainerStyle += ` ${baseStyle}--scrollable-left`; + } + if (this.isRightScrollable) { + innerContainerStyle += ` ${baseStyle}--scrollable-right`; + } + return innerContainerStyle; + } + + @action + observeIntersection(element: HTMLElement): void { + this.intersectionObserver.observe(element); + if (this.containerElement) { + this.containerElement.scrollLeft = this.containerElement.scrollWidth; + } + } + + @action + unobserveIntersection(element: HTMLElement): void { + this.filterEntries = this.filterEntries.filter((e) => e.target !== element); + this.intersectionObserver.unobserve(element); + } + + @action + scrollToRight(): void { + this.containerElement.scrollBy({ left: this.scrollWidth, behavior: 'smooth' }); + } + + @action + scrollToLeft(): void { + this.containerElement.scrollBy({ left: -this.scrollWidth, behavior: 'smooth' }); + } + + private getFilterNodes(): HTMLElement[] { + return Array.from(this.containerElement?.childNodes || []).filter( + (node): node is HTMLElement => node instanceof HTMLElement + ); + } + + private intersectionObserverCallback(entries: IntersectionObserverEntry[]): void { + entries.forEach((entry) => { + if (entry.rootBounds) { + this.filterEntries = [...this.filterEntries.filter((e) => e.target !== entry.target), entry]; + } else { + this.filterEntries = [...this.filterEntries.filter((e) => e.target !== entry.target)]; + } + }); + } +} diff --git a/app/components/o-s-s/scrollable-bar.js b/app/components/o-s-s/scrollable-bar.js new file mode 100644 index 000000000..48d0534a0 --- /dev/null +++ b/app/components/o-s-s/scrollable-bar.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/scrollable-bar'; diff --git a/app/styles/organisms/scrollable-bar.less b/app/styles/organisms/scrollable-bar.less new file mode 100644 index 000000000..74930e28a --- /dev/null +++ b/app/styles/organisms/scrollable-bar.less @@ -0,0 +1,120 @@ +.scrollable-bar-container { + display: flex; + overflow: hidden; + position: relative; + flex: 1; + + .scroll-to-left-btn, + .scroll-to-left-icon { + position: absolute; + left: 0; + z-index: 1; + } + + .scroll-to-left-icon, + .scroll-to-right-icon { + display: flex; + justify-content: center; + align-items: center; + background-color: white; + height: 100%; + width: 12px; + border: none; + + &:hover { + color: var(--color-gray-900); + } + } + + .scroll-to-right-btn, + .scroll-to-right-icon { + position: absolute; + right: 0; + z-index: 1; + } + + .scroll-to-left-btn, + .scroll-to-right-btn { + top: var(--spacing-px-9); + visibility: hidden; + opacity: 0; + transition: all 300ms ease-in-out; + } + + .scroll-to-left-icon, + .scroll-to-right-icon { + visibility: visible; + opacity: 1; + } + + &:hover { + .scroll-to-left-btn, + .scroll-to-right-btn { + visibility: visible; + opacity: 1; + } + } + + .inner-container { + display: inline-flex; + flex-wrap: nowrap; + justify-content: flex-start; + align-items: center; + gap: var(--spacing-px-9); + overflow: auto; + padding: var(--spacing-px-9) var(--spacing-px-3) var(--spacing-px-9) var(--spacing-px-3); + + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .inner-container-btn { + &--scrollable-right::after { + position: absolute; + content: ' '; + right: 0; + height: 100%; + width: 36px; + background: linear-gradient(180deg, rgba(249, 250, 251, 0.11323) 0%, var(--color-gray-50) 100%); + transform: rotate(-90deg); + } + + &--scrollable-left::before { + position: absolute; + content: ' '; + left: 0; + height: 100%; + width: 36px; + background: linear-gradient(180deg, rgba(249, 250, 251, 0.11323) 0%, var(--color-gray-50) 100%); + transform: rotate(90deg); + } + } + + .inner-container-icon { + &--scrollable-right::after { + position: absolute; + content: ' '; + right: 12px; + height: 100%; + width: 36px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, var(--color-white) 100%); + + transform: rotate(-90deg); + } + + &--scrollable-left::before { + position: absolute; + content: ' '; + left: 12px; + height: 100%; + width: 36px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, var(--color-white) 100%); + + transform: rotate(90deg); + } + } +} diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less index 3ca48c95f..0dde3ab2d 100644 --- a/app/styles/oss-components.less +++ b/app/styles/oss-components.less @@ -78,6 +78,7 @@ @import 'organisms/modal-dialog'; @import 'organisms/split-modal'; @import 'organisms/content-panel'; +@import 'organisms/scrollable-bar'; @import 'organisms/scrollable-panel'; @import 'organisms/sidebar'; @import 'organisms/access-panel'; diff --git a/tests/dummy/app/templates/visual.hbs b/tests/dummy/app/templates/visual.hbs index 0d9f5a749..8437ff520 100644 --- a/tests/dummy/app/templates/visual.hbs +++ b/tests/dummy/app/templates/visual.hbs @@ -903,4 +903,48 @@ + +
+
+ Scrollable bar +
+
+
+ With default "button" style: +
+ + <:content as |callbacks|> +
All
+
Replies
+
Applications
+
Emails
+
Payments
+
Applications2
+
Emails2
+
Payments2
+ +
+
+
+
+ With "icon" style: +
+ + <:content as |callbacks|> +
All
+
Replies
+
Applications
+
Emails
+
Payments
+
Applications2
+
Emails2
+
Payments2
+ +
+
+
+
+
\ No newline at end of file diff --git a/tests/integration/components/o-s-s/scrollable-bar-test.ts b/tests/integration/components/o-s-s/scrollable-bar-test.ts new file mode 100644 index 000000000..f6da410ec --- /dev/null +++ b/tests/integration/components/o-s-s/scrollable-bar-test.ts @@ -0,0 +1,105 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, render, settled, waitUntil, type TestContext } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | o-s-s/scrollable-bar', function (hooks) { + setupRenderingTest(hooks); + hooks.beforeEach(function () { + this.items = ['All', 'Replies', 'Applications', 'Emails', 'Payment', 'Drafts']; + }); + + module('With default button', function () { + test('it renders only the left button if the scroll position is already fully at the right', async function (this: TestContext, assert) { + await renderComponentDefaultButton(); + await waitUntil( + function () { + return Boolean(document.querySelector('.scroll-to-left-btn')); + }, + { timeout: 2000 } + ); + assert.dom('.scroll-to-left-btn').exists(); + assert.dom('.scroll-to-right-btn').doesNotExist(); + }); + + test('it renders only the right button if the scroll position is already fully at the left', async function (this: TestContext, assert) { + await renderComponentDefaultButton(); + await settled(); + await waitUntil( + function () { + return Boolean(document.querySelector('.scroll-to-left-btn')); + }, + { timeout: 2000 } + ); + await click('.scroll-to-left-btn'); + await waitUntil( + function () { + return !document.querySelector('.scroll-to-left-btn'); + }, + { timeout: 2000 } + ); + + assert.dom('.scroll-to-left-btn').doesNotExist(); + assert.dom('.scroll-to-right-btn').exists(); + }); + }); + + module('With icon button', function () { + test('it renders only the left button if the scroll position is already fully at the right', async function (this: TestContext, assert) { + await renderComponentWithIcon(); + await waitUntil( + function () { + return Boolean(document.querySelector('.scroll-to-left-icon')); + }, + { timeout: 2000 } + ); + assert.dom('.scroll-to-left-icon').exists(); + assert.dom('.scroll-to-right-icon').doesNotExist(); + }); + + test('it renders only the right button if the scroll position is already fully at the left', async function (this: TestContext, assert) { + await renderComponentWithIcon(); + await settled(); + await waitUntil( + function () { + return Boolean(document.querySelector('.scroll-to-left-icon')); + }, + { timeout: 2000 } + ); + await click('.scroll-to-left-icon'); + await waitUntil( + function () { + return !document.querySelector('.scroll-to-left-icon'); + }, + { timeout: 2000 } + ); + + assert.dom('.scroll-to-left-icon').doesNotExist(); + assert.dom('.scroll-to-right-icon').exists(); + }); + }); + + async function renderComponentDefaultButton() { + await render(hbs` + + <:content as |callbacks|> + {{#each this.items as |item|}} +
{{item}}
+ {{/each}} + +
+ `); + } + + async function renderComponentWithIcon() { + await render(hbs` + + <:content as |callbacks|> + {{#each this.items as |item|}} +
{{item}}
+ {{/each}} + +
+ `); + } +}); From 27ac04efc8c3be990da35f5642e1003d25e1d36a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elodie=20Labb=C3=A9?= Date: Mon, 16 Feb 2026 12:14:14 +0100 Subject: [PATCH 2/3] add story --- .../o-s-s/scrollable-bar.stories.js | 53 +++++++++++++++++++ addon/components/o-s-s/scrollable-bar.ts | 3 +- .../components/o-s-s/scrollable-bar-test.ts | 4 +- 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 addon/components/o-s-s/scrollable-bar.stories.js diff --git a/addon/components/o-s-s/scrollable-bar.stories.js b/addon/components/o-s-s/scrollable-bar.stories.js new file mode 100644 index 000000000..3ef60ff5d --- /dev/null +++ b/addon/components/o-s-s/scrollable-bar.stories.js @@ -0,0 +1,53 @@ +import { hbs } from 'ember-cli-htmlbars'; + +export default { + title: 'Components/OSS::ScrollableBar', + component: 'scrollable-bar', + argTypes: { + buttonStyle: { + description: 'Updates the style of scroll buttons. Defaults to "button"', + table: { + type: { summary: 'button | icon' }, + defaultValue: { summary: 'undefined' } + }, + control: { + type: 'text' + } + } + }, + parameters: { + docs: { + description: { + component: 'Container which automatically handle horizontal scroll behavior with left and right buttons' + } + } + } +}; + +const defaultArgs = { + buttonStyle: undefined +}; + +const Template = (args) => ({ + template: hbs` +
+ + <:content as |callbacks|> +
All
+
Replies
+
Applications
+
Emails
+
Payments
+
Applications2
+
Emails2
+
Payments2
+ +
+
+ `, + + context: args +}); + +export const BasicUsage = Template.bind({}); +BasicUsage.args = defaultArgs; diff --git a/addon/components/o-s-s/scrollable-bar.ts b/addon/components/o-s-s/scrollable-bar.ts index dc572fe99..b91b4f3ba 100644 --- a/addon/components/o-s-s/scrollable-bar.ts +++ b/addon/components/o-s-s/scrollable-bar.ts @@ -29,8 +29,7 @@ export default class extends Component { } get scrollWidth(): number { - // May be updated, waiting for product input - return (this.containerElement.clientWidth / 3) * 2; + return this.containerElement.clientWidth / 3; } get isLeftScrollable(): boolean { diff --git a/tests/integration/components/o-s-s/scrollable-bar-test.ts b/tests/integration/components/o-s-s/scrollable-bar-test.ts index f6da410ec..9081924b9 100644 --- a/tests/integration/components/o-s-s/scrollable-bar-test.ts +++ b/tests/integration/components/o-s-s/scrollable-bar-test.ts @@ -81,7 +81,7 @@ module('Integration | Component | o-s-s/scrollable-bar', function (hooks) { async function renderComponentDefaultButton() { await render(hbs` - + <:content as |callbacks|> {{#each this.items as |item|}}
{{item}}
@@ -93,7 +93,7 @@ module('Integration | Component | o-s-s/scrollable-bar', function (hooks) { async function renderComponentWithIcon() { await render(hbs` - + <:content as |callbacks|> {{#each this.items as |item|}}
{{item}}
From f3e99c4d55a3bb666d264fce77127517f3c18503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elodie=20Labb=C3=A9?= Date: Mon, 16 Feb 2026 16:59:07 +0100 Subject: [PATCH 3/3] update hover behavior --- app/styles/organisms/scrollable-bar.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/styles/organisms/scrollable-bar.less b/app/styles/organisms/scrollable-bar.less index 74930e28a..e12920f87 100644 --- a/app/styles/organisms/scrollable-bar.less +++ b/app/styles/organisms/scrollable-bar.less @@ -22,7 +22,7 @@ border: none; &:hover { - color: var(--color-gray-900); + color: var(--color-gray-500); } }