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.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 new file mode 100644 index 000000000..b91b4f3ba --- /dev/null +++ b/addon/components/o-s-s/scrollable-bar.ts @@ -0,0 +1,102 @@ +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 { + return this.containerElement.clientWidth / 3; + } + + 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..e12920f87 --- /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-500); + } + } + + .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..9081924b9 --- /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}} + +
+ `); + } +});