From 11f8de3bca0798540d445201860d71ee5f94de5f Mon Sep 17 00:00:00 2001 From: Nathalie Date: Mon, 9 Jun 2025 16:55:14 +0200 Subject: [PATCH 01/76] Added new component Smart ToggleButtons --- .../components/o-s-s/smart/toggle-buttons.hbs | 18 ++ .../o-s-s/smart/toggle-buttons.stories.js | 79 +++++++++ .../components/o-s-s/smart/toggle-buttons.ts | 30 ++++ addon/components/o-s-s/toggle-buttons.ts | 8 +- app/components/o-s-s/smart/toggle-buttons.js | 1 + app/styles/molecules/toggle-buttons.less | 38 ++++ tests/dummy/app/controllers/smart.ts | 24 +++ tests/dummy/app/router.js | 1 + tests/dummy/app/routes/smart.ts | 3 + tests/dummy/app/templates/application.hbs | 5 + tests/dummy/app/templates/smart.hbs | 32 ++++ .../o-s-s/smart/toggle-buttons-test.ts | 167 ++++++++++++++++++ 12 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 addon/components/o-s-s/smart/toggle-buttons.hbs create mode 100644 addon/components/o-s-s/smart/toggle-buttons.stories.js create mode 100644 addon/components/o-s-s/smart/toggle-buttons.ts create mode 100644 app/components/o-s-s/smart/toggle-buttons.js create mode 100644 tests/dummy/app/controllers/smart.ts create mode 100644 tests/dummy/app/routes/smart.ts create mode 100644 tests/dummy/app/templates/smart.hbs create mode 100644 tests/integration/components/o-s-s/smart/toggle-buttons-test.ts diff --git a/addon/components/o-s-s/smart/toggle-buttons.hbs b/addon/components/o-s-s/smart/toggle-buttons.hbs new file mode 100644 index 000000000..e7f34b8a9 --- /dev/null +++ b/addon/components/o-s-s/smart/toggle-buttons.hbs @@ -0,0 +1,18 @@ +
+ {{#each @toggles as |toggle|}} +
+ {{#if toggle.icon}} + + {{/if}} + {{toggle.label}} +
+ {{/each}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/toggle-buttons.stories.js b/addon/components/o-s-s/smart/toggle-buttons.stories.js new file mode 100644 index 000000000..2fa7c4189 --- /dev/null +++ b/addon/components/o-s-s/smart/toggle-buttons.stories.js @@ -0,0 +1,79 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Components/OSS::Smart::ToggleButtons', + component: 'smart toggle-buttons', + argTypes: { + toggles: { + type: { required: true }, + description: 'An array of toggles passed to the component', + table: { + type: { + summary: '{value: string, label: string}[]' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' } + }, + selectedToggle: { + type: { required: true }, + description: 'Value selected', + table: { + type: { + summary: 'string | null' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + disabled: { + type: { required: false }, + description: 'Disabled state', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'boolean' } + }, + onSelection: { + type: { required: true }, + description: 'Action triggered when selecting a new toggle', + table: { + category: 'Actions', + type: { + summary: 'onSelection(selectedToggle: string): void' + } + } + } + }, + parameters: { + docs: { + description: { + component: 'The smart version of the toggle-buttons item component' + }, + iframeHeight: 200 + } + } +}; + +const defaultArgs = { + toggles: [ + { value: 'categories', label: 'Categories' }, + { value: 'products', label: 'Products' } + ], + selectedToggle: 'categories', + onSelection: action('onSelection'), + disabled: false +}; +const DefaultUsageTemplate = (args) => ({ + template: hbs` +
+ +
+ `, + context: args +}); + +export const Default = DefaultUsageTemplate.bind({}); +Default.args = defaultArgs; diff --git a/addon/components/o-s-s/smart/toggle-buttons.ts b/addon/components/o-s-s/smart/toggle-buttons.ts new file mode 100644 index 000000000..61935f258 --- /dev/null +++ b/addon/components/o-s-s/smart/toggle-buttons.ts @@ -0,0 +1,30 @@ +import { assert } from '@ember/debug'; +import OSSToggleButtons, { type OSSToggleButtonsArgs } from '../toggle-buttons'; + +interface OSSSmartToggleButtonsArgs extends OSSToggleButtonsArgs {} + +export default class OSSSmartToggleButtons extends OSSToggleButtons { + constructor(owner: unknown, args: OSSSmartToggleButtonsArgs) { + super(owner, args, true); + + assert( + '[component][OSS::Smart::ToggleButtons] The @toggles parameter of type Toggle[] is mandatory', + this.args.toggles instanceof Array + ); + + assert( + '[component][OSS::Smart::ToggleButtons] The @onSelection parameter of type function is mandatory', + typeof args.onSelection === 'function' + ); + + assert( + '[component][OSS::Smart::ToggleButtons] The @selectedToggle parameter of type string or null is mandatory', + args.selectedToggle === null || typeof args.selectedToggle === 'string' + ); + + assert( + '[component][OSS::Smart::ToggleButtons] The @selectedToggle parameter should be null or a value of toggles', + args.selectedToggle === null || args.toggles.map((item) => item.value).includes(args.selectedToggle) + ); + } +} diff --git a/addon/components/o-s-s/toggle-buttons.ts b/addon/components/o-s-s/toggle-buttons.ts index f496e084c..03db57f6e 100644 --- a/addon/components/o-s-s/toggle-buttons.ts +++ b/addon/components/o-s-s/toggle-buttons.ts @@ -8,17 +8,19 @@ export type Toggle = { icon?: string; }; -interface OSSToggleButtonsArgs { +export interface OSSToggleButtonsArgs { toggles: Toggle[]; selectedToggle: string | null; disabled?: boolean; onSelection(selectedToggle: string): void; } -export default class OSSToggleButtons extends Component { - constructor(owner: unknown, args: OSSToggleButtonsArgs) { +export default class OSSToggleButtons extends Component { + constructor(owner: unknown, args: OSSToggleButtonsArgs, preventDefaultAssertions?: boolean) { super(owner, args); + if (preventDefaultAssertions) return; + assert( '[component][OSS::ToggleButtons] The @toggles parameter of type Toggle[] is mandatory', this.args.toggles instanceof Array diff --git a/app/components/o-s-s/smart/toggle-buttons.js b/app/components/o-s-s/smart/toggle-buttons.js new file mode 100644 index 000000000..7418a161b --- /dev/null +++ b/app/components/o-s-s/smart/toggle-buttons.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/toggle-buttons'; diff --git a/app/styles/molecules/toggle-buttons.less b/app/styles/molecules/toggle-buttons.less index 1bce1f2e1..5d75e68ea 100644 --- a/app/styles/molecules/toggle-buttons.less +++ b/app/styles/molecules/toggle-buttons.less @@ -35,3 +35,41 @@ } } } + +.oss-smart-toggle-buttons-container { + .border-radius-lg; + .background-color-primary-50; + + display: flex; + flex: 1; + height: 30px; + padding: 2px; + color: var(--color-primary-300); + + .oss-smart-toggle-buttons-btn { + .border-radius-lg; + + display: flex; + flex: 1; + justify-content: center; + align-items: center; + gap: var(--spacing-px-6); + padding: var(--spacing-px-6) var(--spacing-px-12); + transition: background-color 300ms ease-in-out; + + &--selected { + background-color: var(--color-primary-400); + color: var(--color-white); + box-shadow: var(--box-shadow-xs); + } + } + + &--disabled { + .oss-smart-toggle-buttons-btn { + &--selected { + background-color: var(--color-primary-400); + opacity: 0.5; + } + } + } +} diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts new file mode 100644 index 000000000..d85b04e03 --- /dev/null +++ b/tests/dummy/app/controllers/smart.ts @@ -0,0 +1,24 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +export default class Smart extends Controller { + @tracked selectedToggle: string = 'first'; + @tracked selectedToggleTwo: string = 'second'; + @tracked toggles: { value: string; label: string }[] = [ + { + value: 'first', + label: 'First' + }, + { + value: 'second', + label: 'Second' + } + ]; + + @action + triggerSelection(value: string): void { + console.log('selected toggle value : ', value); + this.selectedToggle = value; + } +} diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js index 19f31e147..f48971b53 100644 --- a/tests/dummy/app/router.js +++ b/tests/dummy/app/router.js @@ -13,4 +13,5 @@ Router.map(function () { this.route('overlay'); this.route('extra'); this.route('wizard'); + this.route('smart'); }); diff --git a/tests/dummy/app/routes/smart.ts b/tests/dummy/app/routes/smart.ts new file mode 100644 index 000000000..52f7fd8e7 --- /dev/null +++ b/tests/dummy/app/routes/smart.ts @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class Smart extends Route {} diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index c34f17bcd..211d9b304 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -36,6 +36,11 @@ class={{if (eq this.router.currentRouteName "wizard") "active"}} @link="wizard" /> + +
+ Smart + Components that look like another component and act like another component but + aren't said component + (also they might be smarter than you who knows) +
+ +
+
+ Smart toggle buttons +
+
+
+ + +
+
+
+ + \ No newline at end of file diff --git a/tests/integration/components/o-s-s/smart/toggle-buttons-test.ts b/tests/integration/components/o-s-s/smart/toggle-buttons-test.ts new file mode 100644 index 000000000..1291b5b87 --- /dev/null +++ b/tests/integration/components/o-s-s/smart/toggle-buttons-test.ts @@ -0,0 +1,167 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { set } from '@ember/object'; +import { click, render } from '@ember/test-helpers'; +import settled from '@ember/test-helpers/settled'; +import setupOnerror from '@ember/test-helpers/setup-onerror'; +import sinon from 'sinon'; + +module('Integration | Component | o-s-s/smart/toggle-buttons', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.selectedToggle = 'first'; + this.onSelection = (value: any) => { + set(this, 'selectedToggle', value); + }; + this.toggles = [ + { + value: 'first', + label: 'First' + }, + { + value: 'second', + label: 'Second', + icon: 'far fa-2' + } + ]; + }); + + test('it renders', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.oss-smart-toggle-buttons-container').exists(); + }); + + test('the right class is applied when the @disabled arg is truthy', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.oss-smart-toggle-buttons-container').exists(); + assert.dom('.oss-smart-toggle-buttons-container').hasClass('oss-smart-toggle-buttons-container--disabled'); + }); + + test('the toggle icon is displayed when provided', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.oss-smart-toggle-buttons-btn:first-child i.far').doesNotExist(); + assert.dom('.oss-smart-toggle-buttons-btn:last-child i.far').exists(); + assert.dom('.oss-smart-toggle-buttons-btn:last-child i.far').hasClass('fa-2'); + }); + + module('If @selectedToggle is passed', function () { + test('If the selectedToggle matches an entry from the toggles, then the toggle is set to selected', async function (assert) { + this.selectedToggle = 'second'; + + await render( + hbs`` + ); + assert.dom('.oss-smart-toggle-buttons-btn--selected').hasText('Second'); + }); + }); + + module('When clicking on an item', () => { + test('the toggle is selected', async function (assert) { + await render( + hbs`` + ); + + await click('.oss-smart-toggle-buttons-btn:first-child'); + assert.dom('.oss-smart-toggle-buttons-btn--selected').hasText('First'); + + await click('.oss-smart-toggle-buttons-btn:last-child'); + assert.dom('.oss-smart-toggle-buttons-btn--selected').hasText('Second'); + }); + + test('the @onSelection method is not triggered if the item is already selected', async function (assert) { + this.onSelectionStub = sinon.stub(); + + await render( + hbs`` + ); + + await click('.oss-smart-toggle-buttons-btn:first-child'); + assert.ok(this.onSelectionStub.notCalled); + }); + + test('the @onSelection method is not triggered if the component is disabled', async function (assert) { + this.onSelectionStub = sinon.stub(); + + await render( + hbs`` + ); + + await click('.oss-smart-toggle-buttons-btn:first-child'); + assert.ok(this.onSelectionStub.notCalled); + }); + + test('the @onSelection method is triggered with the selected value', async function (assert) { + this.onSelection = sinon.spy(); + + await render( + hbs`` + ); + + await click('.oss-smart-toggle-buttons-btn:last-child'); + assert.ok(this.onSelection.calledWith('second')); + }); + }); + + module('Error management', () => { + test('it throws an error if @toggles is not provided', async function (assert) { + setupOnerror((err: any) => { + assert.equal( + err.message, + 'Assertion Failed: [component][OSS::Smart::ToggleButtons] The @toggles parameter of type Toggle[] is mandatory' + ); + }); + await render( + hbs`` + ); + await settled(); + }); + + test('it throws an error if @onSelection is not provided', async function (assert) { + setupOnerror((err: any) => { + assert.equal( + err.message, + 'Assertion Failed: [component][OSS::Smart::ToggleButtons] The @onSelection parameter of type function is mandatory' + ); + }); + await render(hbs``); + await settled(); + }); + + test('it throws an error if @selectedToggle is not provided', async function (assert) { + setupOnerror((err: any) => { + assert.equal( + err.message, + 'Assertion Failed: [component][OSS::Smart::ToggleButtons] The @selectedToggle parameter of type string or null is mandatory' + ); + }); + await render(hbs``); + await settled(); + }); + + test('it throws an error if @selectedToggle is not a value of toggles', async function (assert) { + this.selectedToggle = 'toto'; + + setupOnerror((err: any) => { + assert.equal( + err.message, + 'Assertion Failed: [component][OSS::Smart::ToggleButtons] The @selectedToggle parameter should be null or a value of toggles' + ); + }); + await render( + hbs`` + ); + await settled(); + }); + }); +}); From fa36f7624fe0ed9ea208bf76c8d4bfbc3e9d2a13 Mon Sep 17 00:00:00 2001 From: Nathalie Date: Mon, 9 Jun 2025 17:27:42 +0200 Subject: [PATCH 02/76] Added hover --- app/styles/molecules/toggle-buttons.less | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/styles/molecules/toggle-buttons.less b/app/styles/molecules/toggle-buttons.less index 5d75e68ea..714b52ab8 100644 --- a/app/styles/molecules/toggle-buttons.less +++ b/app/styles/molecules/toggle-buttons.less @@ -45,6 +45,7 @@ height: 30px; padding: 2px; color: var(--color-primary-300); + gap: var(--spacing-px-3); .oss-smart-toggle-buttons-btn { .border-radius-lg; @@ -53,7 +54,6 @@ flex: 1; justify-content: center; align-items: center; - gap: var(--spacing-px-6); padding: var(--spacing-px-6) var(--spacing-px-12); transition: background-color 300ms ease-in-out; @@ -70,6 +70,17 @@ background-color: var(--color-primary-400); opacity: 0.5; } + + &:hover { + } + } + } + + &:not(&--disabled) { + .oss-smart-toggle-buttons-btn { + &:not(&--selected):hover { + background-color: var(--color-white); + } } } } From 6d53972f064ddb94fcd5914c0eff32588a376efb Mon Sep 17 00:00:00 2001 From: Nathalie Date: Tue, 10 Jun 2025 11:53:15 +0200 Subject: [PATCH 03/76] removed empty css rule --- app/styles/molecules/toggle-buttons.less | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/styles/molecules/toggle-buttons.less b/app/styles/molecules/toggle-buttons.less index 714b52ab8..c95c801c8 100644 --- a/app/styles/molecules/toggle-buttons.less +++ b/app/styles/molecules/toggle-buttons.less @@ -70,9 +70,6 @@ background-color: var(--color-primary-400); opacity: 0.5; } - - &:hover { - } } } From 71d6e53c5020b594107e13bcfe5ca5d7c3ebfc23 Mon Sep 17 00:00:00 2001 From: Nathalie Date: Thu, 12 Jun 2025 18:01:54 +0200 Subject: [PATCH 04/76] Added Smart Button component --- addon/components/o-s-s/button.ts | 9 +- addon/components/o-s-s/smart/button.hbs | 30 +++ .../components/o-s-s/smart/button.stories.js | 172 ++++++++++++++ addon/components/o-s-s/smart/button.ts | 57 +++++ .../components/o-s-s/smart/toggle-buttons.ts | 2 +- app/components/o-s-s/smart/button.js | 1 + app/styles/atoms/button.less | 168 +++++++++++++- app/styles/core/_all.less | 1 + app/styles/core/_smart.less | 3 + tests/dummy/app/controllers/smart.ts | 5 + tests/dummy/app/templates/smart.hbs | 136 +++++++++++ .../components/o-s-s/smart/button-test.ts | 215 ++++++++++++++++++ 12 files changed, 794 insertions(+), 5 deletions(-) create mode 100644 addon/components/o-s-s/smart/button.hbs create mode 100644 addon/components/o-s-s/smart/button.stories.js create mode 100644 addon/components/o-s-s/smart/button.ts create mode 100644 app/components/o-s-s/smart/button.js create mode 100644 app/styles/core/_smart.less create mode 100644 tests/integration/components/o-s-s/smart/button-test.ts diff --git a/addon/components/o-s-s/button.ts b/addon/components/o-s-s/button.ts index f6cde2e66..c4f231606 100644 --- a/addon/components/o-s-s/button.ts +++ b/addon/components/o-s-s/button.ts @@ -70,7 +70,7 @@ const SQUARE_CLASS = 'upf-square-btn'; const DEFAULT_COUNTER_TIME = 5000; const DEFAULT_STEP_COUNTER_TIME = 1000; -interface ButtonArgs { +export interface OSSButtonArgs { skin?: string; size?: string; loading?: boolean; @@ -85,17 +85,20 @@ interface ButtonArgs { time?: number; step?: number; }; + // disabled?: boolean; } -export default class OSSButton extends Component { +export default class OSSButton extends Component { @tracked DOMElement: HTMLElement | undefined; @tracked intervalID: ReturnType | undefined; @tracked intervalState: boolean = false; @tracked counterTimeLeft: number = 0; - constructor(owner: unknown, args: ButtonArgs) { + constructor(owner: unknown, args: OSSButtonArgs, preventDefaultAssertions?: boolean) { super(owner, args); + if (preventDefaultAssertions) return; + assert( '[component][OSS::Button] You must pass either a @label, an @icon or an @iconUrl argument.', args.label || args.icon || args.iconUrl diff --git a/addon/components/o-s-s/smart/button.hbs b/addon/components/o-s-s/smart/button.hbs new file mode 100644 index 000000000..dbf55e4e4 --- /dev/null +++ b/addon/components/o-s-s/smart/button.hbs @@ -0,0 +1,30 @@ +{{! template-lint-disable u-template-lint/no-bare-button}} +
+ +
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/button.stories.js b/addon/components/o-s-s/smart/button.stories.js new file mode 100644 index 000000000..e4f58a42e --- /dev/null +++ b/addon/components/o-s-s/smart/button.stories.js @@ -0,0 +1,172 @@ +import { hbs } from 'ember-cli-htmlbars'; + +const SkinTypes = ['primary', 'secondary']; +const SizeTypes = ['xs', 'sm', 'md', 'lg']; + +export default { + title: 'Components/OSS::Smart::Button', + component: 'button', + argTypes: { + skin: { + description: 'Adjust appearance', + table: { + type: { + summary: SkinTypes.join('|') + }, + defaultValue: { summary: 'primary' } + }, + options: SkinTypes, + control: { type: 'select' } + }, + size: { + description: 'Adjust size', + table: { + type: { + summary: SizeTypes.join('|') + }, + defaultValue: { summary: 'null' } + }, + options: SizeTypes, + control: { type: 'select' } + }, + loading: { + description: 'Display loading state', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: 'false' } + }, + control: { type: 'boolean' } + }, + loadingOptions: { + description: 'Options to configure the loading state', + table: { + type: { + summary: '{ showLabel?: boolean }' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' } + }, + label: { + description: 'Text content of the button', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'undefined' } + }, + control: { + type: 'text' + } + }, + icon: { + description: 'Font Awesome class, for example: far fa-envelope-open', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'undefined' } + }, + control: { + type: 'text' + } + }, + iconUrl: { + description: 'Url of an icon that will be shown within the button', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'undefined' } + }, + control: { + type: 'text' + } + }, + circle: { + description: 'Displays the button as a circle. Useful for icon buttons.', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' } + }, + control: { + type: 'boolean' + } + }, + countDown: { + description: + 'Definition of countDown object, it takes 3 keys:
' + + "- 'callback' (mandatory): function to call at the end
" + + "- 'time' (optional): time between execute callback. It is representing entire second in millisecond, for exemple 1000, 2000 or 5000
" + + "- 'step' (optional): the step value, it should be in the same unit as the time", + table: { + type: { + summary: '{ callback: () => {}, time?: number, step?: number }' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' } + }, + disabled: { + description: 'Disables the button', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + } + }, + parameters: { + docs: { + description: { + component: 'The smart version of the button component. Configurable & skinable.' + } + } + } +}; + +const defaultArgs = { + skin: 'primary', + size: 'md', + loading: false, + label: 'Label', + icon: 'far fa-envelope-open', + circle: false, + countDown: undefined, + loadingOptions: undefined, + iconUrl: undefined, + disabled: false +}; + +const Template = (args) => ({ + template: hbs` + + `, + context: args +}); + +export const Default = Template.bind({}); +Default.args = defaultArgs; + +export const WithCountDown = Template.bind({}); +WithCountDown.args = { + ...defaultArgs, + ...{ + countDown: { + callback: function () { + alert('Count down finish'); + }, + time: 3000 + } + } +}; + +export const WithIconUrl = Template.bind({}); +WithIconUrl.args = { + ...defaultArgs, + ...{ + icon: undefined, + iconUrl: '/@upfluence/oss-components/assets/star-icon.svg' + } +}; diff --git a/addon/components/o-s-s/smart/button.ts b/addon/components/o-s-s/smart/button.ts new file mode 100644 index 000000000..244af5894 --- /dev/null +++ b/addon/components/o-s-s/smart/button.ts @@ -0,0 +1,57 @@ +import { assert } from '@ember/debug'; +import OSSButton, { type OSSButtonArgs } from '../button'; + +type SmartSkinType = 'primary' | 'secondary'; + +type SmartSkinDefType = { + [key in SmartSkinType]: string; +}; + +const SmartSkinDefinition: SmartSkinDefType = { + primary: 'primary', + secondary: 'secondary' +}; + +const SMART_BASE_CLASS = 'upf-smart-btn'; +const SMART_SQUARE_CLASS = 'upf-smart-square-btn'; + +interface OSSSmartButtonArgs extends OSSButtonArgs { + disabled?: boolean; + circle?: boolean; +} + +export default class OSSSmartButton extends OSSButton { + constructor(owner: unknown, args: OSSSmartButtonArgs) { + super(owner, args, true); + + assert( + '[component][OSS::Smart::Button] You must pass either a @label, an @icon or an @iconUrl argument.', + args.label || args.icon || args.iconUrl + ); + assert( + "[component][OSS::Smart::Button] You must pass either a hash with 'callback' value to @countDown argument.", + args.countDown ? args.countDown.callback : true + ); + } + + get isCircle(): boolean { + return this.args.square || this.args.circle || false; + } + + get smartSkin(): string { + if (!this.args.skin) { + return SmartSkinDefinition.primary; + } + return SmartSkinDefinition[this.args.skin as SmartSkinType] ?? SmartSkinDefinition.primary; + } + + get computedSmartClasses(): string { + let classes = [this.isCircle ? SMART_SQUARE_CLASS : SMART_BASE_CLASS, `upf-smart-btn--${this.smartSkin}`]; + + if (this.size) { + classes.push(this.isCircle ? `upf-smart-square-btn--${this.size}` : `upf-smart-btn--${this.size}`); + } + + return classes.join(' '); + } +} diff --git a/addon/components/o-s-s/smart/toggle-buttons.ts b/addon/components/o-s-s/smart/toggle-buttons.ts index 61935f258..2dc5abf76 100644 --- a/addon/components/o-s-s/smart/toggle-buttons.ts +++ b/addon/components/o-s-s/smart/toggle-buttons.ts @@ -3,7 +3,7 @@ import OSSToggleButtons, { type OSSToggleButtonsArgs } from '../toggle-buttons'; interface OSSSmartToggleButtonsArgs extends OSSToggleButtonsArgs {} -export default class OSSSmartToggleButtons extends OSSToggleButtons { +export default class OSSSmartToggleButtons extends OSSToggleButtons { constructor(owner: unknown, args: OSSSmartToggleButtonsArgs) { super(owner, args, true); diff --git a/app/components/o-s-s/smart/button.js b/app/components/o-s-s/smart/button.js new file mode 100644 index 000000000..cd9027367 --- /dev/null +++ b/app/components/o-s-s/smart/button.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/button'; diff --git a/app/styles/atoms/button.less b/app/styles/atoms/button.less index f3100c507..c58de8b3d 100644 --- a/app/styles/atoms/button.less +++ b/app/styles/atoms/button.less @@ -337,12 +337,178 @@ .generate-extended-palette-btn(violet); } +.upf-smart-btn-container { + --color-smart-primary-400: linear-gradient(104.04deg, #535efc 0%, #ed21ff 100%); + --color-smart-primary-500: linear-gradient(104.04deg, #0d0de6 0%, #db00ff 100%); + --color-smart-primary-600: linear-gradient(104.04deg, #0505b8 0%, #b700cf 100%); + + position: relative; + pointer-events: none; + height: fit-content; + width: fit-content; + + button { + pointer-events: all; + } + + &:has(> .upf-smart-btn--primary:not(:disabled)) { + &::before { + content: ''; + width: calc(100% + 4px); + height: calc(100% + 4px); + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 0; + border-radius: 999px; + background: var(--color-smart-primary-400); + opacity: 0; + transition: opacity 0.25s ease-in-out; + } + + &:hover, + &:active { + &::before { + opacity: 0.4; + } + } + } + + .upf-smart-btn { + .upf-btn; + flex: 1; + border-radius: var(--border-radius-xl); + + &:disabled { + .disabled-button; + } + + &--xs, + &--sm { + height: var(--spacing-px-24); + padding: var(--spacing-px-3) var(--spacing-px-12); + + a& { + line-height: 2rem; + } + } + + &--md { + height: var(--spacing-px-36); + } + + &--lg { + height: var(--spacing-px-48); + } + + img { + max-width: 12px; + max-height: 12px; + } + } + + .upf-smart-btn--primary { + position: relative; + overflow: hidden; + z-index: 0; + background: var(--color-smart-primary-500); + border: 1px solid var(--color-smart-primary-500); + color: var(--color-white); + + a& { + line-height: 2.4rem; + } + + &:not(:disabled)::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: var(--color-smart-primary-600); + border: 1px solid var(--color-smart-primary-600); + opacity: 0; + transition: opacity 0.25s ease-in-out; + } + + &:not(:disabled):hover::after { + opacity: 1; + } + + &:not(:disabled):active::after { + background: var(--color-smart-primary-400); + border: 1px solid var(--color-smart-primary-400); + } + + > * { + position: relative; + z-index: 1; + } + + &:disabled { + background: none; + .disabled-button; + + &::after { + content: none; + } + } + } + + .upf-smart-btn--secondary { + background-color: var(--color-violet-50); + border: 1px solid var(--color-violet-500); + color: var(--color-violet-500); + + a& { + line-height: 2.4rem; + } + + &:hover, + &:active { + position: relative; + color: var(--color-white); + box-shadow: 0px 0px 0px 2px var(--color-violet-100), var(--box-shadow-xs); + } + + &:hover { + background-color: var(--color-violet-600); + border: 1px solid var(--color-violet-600); + } + + &:active { + background-color: var(--color-violet-400); + border: 1px solid var(--color-violet-400); + } + + &:disabled { + &:hover, + &:active { + .disabled-button; + box-shadow: none; + } + } + } +} + +.upf-smart-square-btn { + .upf-square-btn; + height: var(--spacing-px-36); + width: var(--spacing-px-36); + border-radius: var(--border-radius-xl); + + img { + max-width: 12px; + max-height: 12px; + } +} + // Button Sizes // -------------------------------------------------- .upf-btn--small, .upf-btn--sm { - height: 24px; + height: var(--spacing-px-24); padding: var(--spacing-px-3) var(--spacing-px-12); a& { diff --git a/app/styles/core/_all.less b/app/styles/core/_all.less index 6446ac244..f1adba3f1 100644 --- a/app/styles/core/_all.less +++ b/app/styles/core/_all.less @@ -3,3 +3,4 @@ @import '_typography'; @import '_flex'; @import '_form'; +@import '_smart'; diff --git a/app/styles/core/_smart.less b/app/styles/core/_smart.less new file mode 100644 index 000000000..42f1e5f06 --- /dev/null +++ b/app/styles/core/_smart.less @@ -0,0 +1,3 @@ +:root { + --border-radius-xl: 999px; +} diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index d85b04e03..74f983353 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -16,6 +16,11 @@ export default class Smart extends Controller { } ]; + @action + countDownAction(): void { + console.log('countDownAction'); + } + @action triggerSelection(value: string): void { console.log('selected toggle value : ', value); diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index 0f5061314..c81887ea0 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -6,6 +6,142 @@ (also they might be smarter than you who knows) +
+
+ Button +
+
+
+ + + + +
+ + +
+
+
+ + + + +
+ + +
+
+
+ + + + + +
+
+ + + + +
+ + +
+
+
+ + + + +
+ + +
+
+
+ + + + + +
+
+
+
diff --git a/tests/integration/components/o-s-s/smart/button-test.ts b/tests/integration/components/o-s-s/smart/button-test.ts new file mode 100644 index 000000000..b2cc9e782 --- /dev/null +++ b/tests/integration/components/o-s-s/smart/button-test.ts @@ -0,0 +1,215 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click, setupOnerror, waitUntil } from '@ember/test-helpers'; + +import sinon from 'sinon'; + +module('Integration | Component | o-s-s/smart/button', function (hooks) { + setupRenderingTest(hooks); + + test('it renders the label when present', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn span').hasText('Label'); + }); + + test('it renders the icon when present', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn i').hasClass('fa-facebook'); + }); + + test('it renders the iconUrl when present', async function (assert) { + await render(hbs``); + assert.dom('.upf-smart-btn img').hasAttribute('src', '/@upfluence/oss-components/assets/star-icon.svg'); + }); + + test('it renders the icon and label when present', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn i').hasClass('fa-facebook'); + assert.dom('.upf-smart-btn span').hasText('Label'); + }); + + test('it renders the iconUrl and label when present', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn img').hasAttribute('src', '/@upfluence/oss-components/assets/star-icon.svg'); + assert.dom('.upf-smart-btn span').hasText('Label'); + }); + + test('when icon and iconUrl are present, it only renders the icon', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.upf-smart-btn i').hasClass('fa-facebook'); + assert.dom('.upf-smart-btn img').doesNotExist(); + }); + + module('it render with the correct skin', function () { + test('it renders the primary skin as default', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn').exists({ count: 1 }); + assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--primary'); + assert.dom('.upf-smart-btn').hasText('Test'); + }); + + test('when using an unknown skin, it is set to primary', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--primary'); + }); + + test('when using primary skin', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--primary'); + }); + + test('when using secondary skin', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--secondary'); + }); + }); + + module('it render with the right size', function () { + test('when using xs', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--xs'); + }); + + test('when using sm', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--sm'); + }); + + test('when using md', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--md'); + }); + + test('when using lg', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--lg'); + }); + }); + + module('it renders with loading state', function () { + test('when using default loading', async function (assert) { + await render(hbs``); + assert.dom('.upf-smart-btn i.fas').exists(); + assert.dom('.upf-smart-btn i.fas').hasClass('fa-spinner-third'); + assert.dom('.upf-smart-btn i.fas').hasClass('fa-spin'); + assert.dom('.upf-smart-btn span.margin-left-px-6').doesNotExist(); + }); + + test('when loading and the showLabel loading option is truthy, the label is displayed', async function (assert) { + await render( + hbs`` + ); + assert.dom('.upf-smart-btn i.fas').exists(); + assert.dom('.upf-smart-btn i.fas').hasClass('fa-spinner-third'); + assert.dom('.upf-smart-btn i.fas').hasClass('fa-spin'); + assert.dom('.upf-smart-btn span.margin-left-px-6').exists(); + assert.dom('.upf-smart-btn span.margin-left-px-6').hasText('Test'); + }); + }); + + module('when @circle is truthy', function () { + test('it renders a circular button', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-square-btn').exists(); + }); + + test('if the icon and label are present it renders only the icon', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-btn i').hasClass('fa-facebook'); + assert.dom('.upf-smart-btn span').hasText('Label'); + }); + + test('if the iconUrl and label are present it renders only the iconUrl', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.upf-smart-btn img').hasAttribute('src', '/@upfluence/oss-components/assets/star-icon.svg'); + assert.dom('.upf-smart-btn span').hasText('Label'); + }); + }); + + module('it renders countDown', function (hooks) { + hooks.beforeEach(function () { + this.intlService = this.owner.lookup('service:intl'); + }); + + test('when clicking, it triggers the countdown', async function (assert) { + this.callback = () => {}; + await render(hbs``); + await click('.upf-smart-btn--primary'); + + assert + .dom('.upf-smart-btn--primary') + .hasText(this.intlService.t('oss-components.button.cancel_message', { time: 5 })); + }); + + test('when clicking, it executes the callback at the end of the countdown', async function (assert) { + this.callback = sinon.stub().callsFake(() => {}); + await render( + hbs`` + ); + await click('.upf-smart-btn--primary'); + + await waitUntil( + function () { + return document.querySelector('.upf-smart-btn--primary')?.textContent?.includes('Test'); + }, + { timeout: 1000 } + ); + + assert.true(this.callback.calledOnce); + }); + + test('when clicking again, it cancels the countdown', async function (assert) { + this.callback = () => {}; + await render(hbs``); + await click('.upf-smart-btn--primary'); + await click('.upf-smart-btn--primary'); + + assert.dom('.upf-smart-btn--primary').hasText('Test'); + }); + }); + + module('Error management', function () { + test('it fails if @label, @icon and @iconUrl are missing', async function (assert) { + setupOnerror((err: { message: string }) => { + assert.equal( + err.message, + 'Assertion Failed: [component][OSS::Smart::Button] You must pass either a @label, an @icon or an @iconUrl argument.' + ); + }); + + await render(hbs``); + }); + + test('it fails if callback missing for @countDown argument', async function (assert) { + setupOnerror((err: { message: string }) => { + assert.equal( + err.message, + "Assertion Failed: [component][OSS::Smart::Button] You must pass either a hash with 'callback' value to @countDown argument." + ); + }); + + await render(hbs``); + }); + }); +}); From 520e1a623b5b1e7011e4ed72d2fb1c768813cc32 Mon Sep 17 00:00:00 2001 From: Nathalie Date: Fri, 13 Jun 2025 10:26:26 +0200 Subject: [PATCH 05/76] Fixed initial PR feedback --- addon/components/o-s-s/button.ts | 1 - addon/components/o-s-s/smart/button.hbs | 6 +- .../components/o-s-s/smart/button.stories.js | 31 +------ addon/components/o-s-s/smart/button.ts | 4 - app/styles/atoms/button.less | 2 +- tests/dummy/app/controllers/smart.ts | 5 -- tests/dummy/app/templates/smart.hbs | 14 ---- .../components/o-s-s/smart/button-test.ts | 81 ++++--------------- 8 files changed, 17 insertions(+), 127 deletions(-) diff --git a/addon/components/o-s-s/button.ts b/addon/components/o-s-s/button.ts index c4f231606..f01400b1c 100644 --- a/addon/components/o-s-s/button.ts +++ b/addon/components/o-s-s/button.ts @@ -85,7 +85,6 @@ export interface OSSButtonArgs { time?: number; step?: number; }; - // disabled?: boolean; } export default class OSSButton extends Component { diff --git a/addon/components/o-s-s/smart/button.hbs b/addon/components/o-s-s/smart/button.hbs index dbf55e4e4..62d1b97a4 100644 --- a/addon/components/o-s-s/smart/button.hbs +++ b/addon/components/o-s-s/smart/button.hbs @@ -7,11 +7,8 @@ {{did-insert this.didInsert}} {{on "click" this.onclick}} > - {{#if this.intervalState}} - {{t "oss-components.button.cancel_message" time=this.counterTimeLeftSecond}} - {{else if this.loadingState}} + {{#if this.loadingState}} - {{#if (and @label @loadingOptions.showLabel)}} {{@label}} {{/if}} @@ -21,7 +18,6 @@ {{else if @iconUrl}} icon {{/if}} - {{#if (and @label (not this.isCircle))}} {{@label}} {{/if}} diff --git a/addon/components/o-s-s/smart/button.stories.js b/addon/components/o-s-s/smart/button.stories.js index e4f58a42e..8c3aa3cae 100644 --- a/addon/components/o-s-s/smart/button.stories.js +++ b/addon/components/o-s-s/smart/button.stories.js @@ -89,20 +89,6 @@ export default { type: 'boolean' } }, - countDown: { - description: - 'Definition of countDown object, it takes 3 keys:
' + - "- 'callback' (mandatory): function to call at the end
" + - "- 'time' (optional): time between execute callback. It is representing entire second in millisecond, for exemple 1000, 2000 or 5000
" + - "- 'step' (optional): the step value, it should be in the same unit as the time", - table: { - type: { - summary: '{ callback: () => {}, time?: number, step?: number }' - }, - defaultValue: { summary: 'undefined' } - }, - control: { type: 'object' } - }, disabled: { description: 'Disables the button', table: { @@ -130,7 +116,6 @@ const defaultArgs = { label: 'Label', icon: 'far fa-envelope-open', circle: false, - countDown: undefined, loadingOptions: undefined, iconUrl: undefined, disabled: false @@ -140,8 +125,7 @@ const Template = (args) => ({ template: hbs` + @circle={{this.circle}} @iconUrl={{this.iconUrl}} @loadingOptions={{this.loadingOptions}} @disabled={{this.disabled}} /> `, context: args }); @@ -149,19 +133,6 @@ const Template = (args) => ({ export const Default = Template.bind({}); Default.args = defaultArgs; -export const WithCountDown = Template.bind({}); -WithCountDown.args = { - ...defaultArgs, - ...{ - countDown: { - callback: function () { - alert('Count down finish'); - }, - time: 3000 - } - } -}; - export const WithIconUrl = Template.bind({}); WithIconUrl.args = { ...defaultArgs, diff --git a/addon/components/o-s-s/smart/button.ts b/addon/components/o-s-s/smart/button.ts index 244af5894..d7e18e9b0 100644 --- a/addon/components/o-s-s/smart/button.ts +++ b/addon/components/o-s-s/smart/button.ts @@ -28,10 +28,6 @@ export default class OSSSmartButton extends OSSButton { '[component][OSS::Smart::Button] You must pass either a @label, an @icon or an @iconUrl argument.', args.label || args.icon || args.iconUrl ); - assert( - "[component][OSS::Smart::Button] You must pass either a hash with 'callback' value to @countDown argument.", - args.countDown ? args.countDown.callback : true - ); } get isCircle(): boolean { diff --git a/app/styles/atoms/button.less b/app/styles/atoms/button.less index c58de8b3d..4534d72d5 100644 --- a/app/styles/atoms/button.less +++ b/app/styles/atoms/button.less @@ -361,7 +361,7 @@ top: 50%; transform: translate(-50%, -50%); z-index: 0; - border-radius: 999px; + border-radius: var(--border-radius-xl); background: var(--color-smart-primary-400); opacity: 0; transition: opacity 0.25s ease-in-out; diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index 74f983353..d85b04e03 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -16,11 +16,6 @@ export default class Smart extends Controller { } ]; - @action - countDownAction(): void { - console.log('countDownAction'); - } - @action triggerSelection(value: string): void { console.log('selected toggle value : ', value); diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index c81887ea0..d8bac4858 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -68,13 +68,6 @@ - -
@@ -131,13 +124,6 @@ - -
diff --git a/tests/integration/components/o-s-s/smart/button-test.ts b/tests/integration/components/o-s-s/smart/button-test.ts index b2cc9e782..019ef3026 100644 --- a/tests/integration/components/o-s-s/smart/button-test.ts +++ b/tests/integration/components/o-s-s/smart/button-test.ts @@ -48,7 +48,7 @@ module('Integration | Component | o-s-s/smart/button', function (hooks) { assert.dom('.upf-smart-btn img').doesNotExist(); }); - module('it render with the correct skin', function () { + module('skin rendering', function () { test('it renders the primary skin as default', async function (assert) { await render(hbs``); @@ -57,53 +57,53 @@ module('Integration | Component | o-s-s/smart/button', function (hooks) { assert.dom('.upf-smart-btn').hasText('Test'); }); - test('when using an unknown skin, it is set to primary', async function (assert) { + test('when using an unknown skin, it defaults to primary', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--primary'); }); - test('when using primary skin', async function (assert) { + test('when the skin is primary, the proper skin is rendered', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--primary'); }); - test('when using secondary skin', async function (assert) { + test('when the skin is secondary, the proper skin is rendered', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--secondary'); }); }); - module('it render with the right size', function () { - test('when using xs', async function (assert) { + module('size rendering', function () { + test('when using xs, it renders the corresponding size', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--xs'); }); - test('when using sm', async function (assert) { + test('when using sm, it renders the corresponding size', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--sm'); }); - test('when using md', async function (assert) { + test('when using md, it renders the corresponding size', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--md'); }); - test('when using lg', async function (assert) { + test('when using lg, it renders the corresponding size', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn').hasClass('upf-smart-btn--lg'); }); }); - module('it renders with loading state', function () { - test('when using default loading', async function (assert) { + module('loading state', function () { + test('when using the default loading, it renders the loading state without the label', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn i.fas').exists(); assert.dom('.upf-smart-btn i.fas').hasClass('fa-spinner-third'); @@ -111,7 +111,7 @@ module('Integration | Component | o-s-s/smart/button', function (hooks) { assert.dom('.upf-smart-btn span.margin-left-px-6').doesNotExist(); }); - test('when loading and the showLabel loading option is truthy, the label is displayed', async function (assert) { + test('when both loading and the showLabel loading option are truthy, the label is also displayed', async function (assert) { await render( hbs`` ); @@ -130,14 +130,14 @@ module('Integration | Component | o-s-s/smart/button', function (hooks) { assert.dom('.upf-smart-square-btn').exists(); }); - test('if the icon and label are present it renders only the icon', async function (assert) { + test('if the icon and label are present, it renders only the icon', async function (assert) { await render(hbs``); assert.dom('.upf-smart-btn i').hasClass('fa-facebook'); assert.dom('.upf-smart-btn span').hasText('Label'); }); - test('if the iconUrl and label are present it renders only the iconUrl', async function (assert) { + test('if both the iconUrl and label are present, it renders only the iconUrl', async function (assert) { await render( hbs`` ); @@ -147,48 +147,6 @@ module('Integration | Component | o-s-s/smart/button', function (hooks) { }); }); - module('it renders countDown', function (hooks) { - hooks.beforeEach(function () { - this.intlService = this.owner.lookup('service:intl'); - }); - - test('when clicking, it triggers the countdown', async function (assert) { - this.callback = () => {}; - await render(hbs``); - await click('.upf-smart-btn--primary'); - - assert - .dom('.upf-smart-btn--primary') - .hasText(this.intlService.t('oss-components.button.cancel_message', { time: 5 })); - }); - - test('when clicking, it executes the callback at the end of the countdown', async function (assert) { - this.callback = sinon.stub().callsFake(() => {}); - await render( - hbs`` - ); - await click('.upf-smart-btn--primary'); - - await waitUntil( - function () { - return document.querySelector('.upf-smart-btn--primary')?.textContent?.includes('Test'); - }, - { timeout: 1000 } - ); - - assert.true(this.callback.calledOnce); - }); - - test('when clicking again, it cancels the countdown', async function (assert) { - this.callback = () => {}; - await render(hbs``); - await click('.upf-smart-btn--primary'); - await click('.upf-smart-btn--primary'); - - assert.dom('.upf-smart-btn--primary').hasText('Test'); - }); - }); - module('Error management', function () { test('it fails if @label, @icon and @iconUrl are missing', async function (assert) { setupOnerror((err: { message: string }) => { @@ -200,16 +158,5 @@ module('Integration | Component | o-s-s/smart/button', function (hooks) { await render(hbs``); }); - - test('it fails if callback missing for @countDown argument', async function (assert) { - setupOnerror((err: { message: string }) => { - assert.equal( - err.message, - "Assertion Failed: [component][OSS::Smart::Button] You must pass either a hash with 'callback' value to @countDown argument." - ); - }); - - await render(hbs``); - }); }); }); From 2b1a4ba7b4568fa0bd81b4a6628e54550f298bdf Mon Sep 17 00:00:00 2001 From: Nathalie Date: Mon, 16 Jun 2025 16:57:06 +0200 Subject: [PATCH 06/76] Removed redundant color variables --- app/styles/atoms/button.less | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/app/styles/atoms/button.less b/app/styles/atoms/button.less index 4534d72d5..75aa59746 100644 --- a/app/styles/atoms/button.less +++ b/app/styles/atoms/button.less @@ -338,10 +338,6 @@ } .upf-smart-btn-container { - --color-smart-primary-400: linear-gradient(104.04deg, #535efc 0%, #ed21ff 100%); - --color-smart-primary-500: linear-gradient(104.04deg, #0d0de6 0%, #db00ff 100%); - --color-smart-primary-600: linear-gradient(104.04deg, #0505b8 0%, #b700cf 100%); - position: relative; pointer-events: none; height: fit-content; @@ -362,7 +358,7 @@ transform: translate(-50%, -50%); z-index: 0; border-radius: var(--border-radius-xl); - background: var(--color-smart-primary-400); + background: var(--color-smart-gradient-400); opacity: 0; transition: opacity 0.25s ease-in-out; } @@ -412,8 +408,8 @@ position: relative; overflow: hidden; z-index: 0; - background: var(--color-smart-primary-500); - border: 1px solid var(--color-smart-primary-500); + background: var(--color-smart-gradient-500); + border: 1px solid var(--color-smart-gradient-500); color: var(--color-white); a& { @@ -425,8 +421,8 @@ position: absolute; inset: 0; z-index: -1; - background: var(--color-smart-primary-600); - border: 1px solid var(--color-smart-primary-600); + background: var(--color-smart-gradient-600); + border: 1px solid var(--color-smart-gradient-600); opacity: 0; transition: opacity 0.25s ease-in-out; } @@ -436,8 +432,8 @@ } &:not(:disabled):active::after { - background: var(--color-smart-primary-400); - border: 1px solid var(--color-smart-primary-400); + background: var(--color-smart-gradient-400); + border: 1px solid var(--color-smart-gradient-400); } > * { From 52beefe468c0849bff129404f4912cd8037eca73 Mon Sep 17 00:00:00 2001 From: Nathalie Date: Mon, 16 Jun 2025 16:49:42 +0200 Subject: [PATCH 07/76] Added new Smart Skeleton component --- addon/components/o-s-s/skeleton.ts | 8 +- addon/components/o-s-s/smart/skeleton.hbs | 11 ++ .../o-s-s/smart/skeleton.stories.js | 101 ++++++++++++ addon/components/o-s-s/smart/skeleton.ts | 51 ++++++ app/components/o-s-s/smart/skeleton.js | 1 + app/styles/utilities/_loading.less | 28 ++++ tests/dummy/app/templates/smart.hbs | 27 +++ .../components/o-s-s/smart/skeleton-test.ts | 155 ++++++++++++++++++ 8 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 addon/components/o-s-s/smart/skeleton.hbs create mode 100644 addon/components/o-s-s/smart/skeleton.stories.js create mode 100644 addon/components/o-s-s/smart/skeleton.ts create mode 100644 app/components/o-s-s/smart/skeleton.js create mode 100644 tests/integration/components/o-s-s/smart/skeleton-test.ts diff --git a/addon/components/o-s-s/skeleton.ts b/addon/components/o-s-s/skeleton.ts index 737f1019c..ae709bb94 100644 --- a/addon/components/o-s-s/skeleton.ts +++ b/addon/components/o-s-s/skeleton.ts @@ -2,7 +2,7 @@ import Component from '@glimmer/component'; import { assert } from '@ember/debug'; import { htmlSafe } from '@ember/template'; -interface OSSSkeletonArgs { +export interface OSSSkeletonArgs { width?: number | string; height?: number | string; multiple?: number; @@ -13,10 +13,12 @@ interface OSSSkeletonArgs { const RANGE_PERCENTAGE: number = 15; -export default class OSSSkeleton extends Component { - constructor(owner: unknown, args: OSSSkeletonArgs) { +export default class OSSSkeleton extends Component { + constructor(owner: unknown, args: OSSSkeletonArgs, preventDefaultAssertions?: boolean) { super(owner, args); + if (preventDefaultAssertions) return; + if (this.args.direction) { assert( `[component][OSS::Skeleton] The @direction argument should be a value of ${['row', 'column', 'col']}`, diff --git a/addon/components/o-s-s/smart/skeleton.hbs b/addon/components/o-s-s/smart/skeleton.hbs new file mode 100644 index 000000000..46b597779 --- /dev/null +++ b/addon/components/o-s-s/smart/skeleton.hbs @@ -0,0 +1,11 @@ +{{#if @multiple}} +
+ {{#each this.rows as |row|}} +
+ {{/each}} +
+{{else}} + {{#each this.rows as |row|}} +
+ {{/each}} +{{/if}} \ No newline at end of file diff --git a/addon/components/o-s-s/smart/skeleton.stories.js b/addon/components/o-s-s/smart/skeleton.stories.js new file mode 100644 index 000000000..c14a8c6e8 --- /dev/null +++ b/addon/components/o-s-s/smart/skeleton.stories.js @@ -0,0 +1,101 @@ +import { hbs } from 'ember-cli-htmlbars'; + +const DirectionTypes = ['row', 'col', 'column']; + +export default { + title: 'Components/OSS::Smart::Skeleton', + component: 'smart-skeleton', + argTypes: { + height: { + description: 'Box height in px', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: '36' } + }, + control: { type: 'number' } + }, + width: { + description: 'Box width in px', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: '36' } + }, + control: { type: 'number' } + }, + multiple: { + description: 'How many skeleton effects should be displayed', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: '1' } + }, + control: { type: 'number' } + }, + gap: { + description: 'Gap between multiple rows in px', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: '9' } + }, + control: { type: 'number' } + }, + randomize: { + description: 'Randomize skeleton effect width within a 15% range', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + direction: { + description: 'Direction of the skeleton', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'row' } + }, + options: DirectionTypes, + control: { type: 'select' } + } + }, + parameters: { + docs: { + description: { + component: 'Component used to create a smart skeleton effect.' + }, + iframeHeight: 250 + } + } +}; + +const defaultArgs = { + height: 200, + width: 300, + multiple: 1, + gap: 9, + randomize: false +}; + +const Template = (args) => ({ + template: hbs` +
+ +
+ `, + context: args +}); + +export const Default = Template.bind({}); +Default.args = defaultArgs; diff --git a/addon/components/o-s-s/smart/skeleton.ts b/addon/components/o-s-s/smart/skeleton.ts new file mode 100644 index 000000000..341ab7ac1 --- /dev/null +++ b/addon/components/o-s-s/smart/skeleton.ts @@ -0,0 +1,51 @@ +import { helper } from '@ember/component/helper'; +import type { OSSSkeletonArgs } from '../skeleton'; +import OSSSkeleton from '../skeleton'; +import { assert } from '@ember/debug'; + +interface OSSSmartSkeletonArgs extends OSSSkeletonArgs {} + +const MIN_HEIGHT = 10; + +export default class OSSSmartSkeleton extends OSSSkeleton { + constructor(owner: unknown, args: OSSSmartSkeletonArgs) { + super(owner, args, true); + + if (this.args.direction) { + assert( + `[component][OSS::Smart::Skeleton] The @direction argument should be a value of ${['row', 'column', 'col']}`, + ['row', 'column', 'col'].includes(this.args.direction) + ); + } + } + + inlineStyles = helper((_, { rowStyle }: { rowStyle: string }): string => { + return [rowStyle, this.backgroundImage].join('; '); + }); + + get height(): number { + return parseInt((this.args.height || MIN_HEIGHT) as string); + } + + get rotationDegrees(): number { + const maxHeight = 100; + const clampedHeight = Math.max(MIN_HEIGHT, Math.min(this.height, maxHeight)) * 1.3; + const minDegree = 100; + const maxDegree = 150; + const logMin = Math.log(MIN_HEIGHT); + const logMax = Math.log(maxHeight); + const scale = (Math.log(clampedHeight) - logMin) / (logMax - logMin); + return Math.round(maxDegree - scale * (maxDegree - minDegree)); + } + + get backgroundImage(): string { + return `background-image: linear-gradient( + ${this.rotationDegrees}deg, + rgba(255, 255, 255, 0.15) 8.2%, + rgba(247, 213, 250, 0.15) 23.6%, + rgba(83, 94, 252, 0.15) 38.3%, + rgba(237, 33, 255, 0.15) 53.2%, + rgba(255, 255, 255, 0.15) 91.7% + );`; + } +} diff --git a/app/components/o-s-s/smart/skeleton.js b/app/components/o-s-s/smart/skeleton.js new file mode 100644 index 000000000..8d2ddefb6 --- /dev/null +++ b/app/components/o-s-s/smart/skeleton.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/skeleton'; diff --git a/app/styles/utilities/_loading.less b/app/styles/utilities/_loading.less index adbb39cc8..cafb9ac78 100644 --- a/app/styles/utilities/_loading.less +++ b/app/styles/utilities/_loading.less @@ -18,3 +18,31 @@ display: inline-block; line-height: 1; } + +@keyframes upf-smart-skeleton-content { + 0% { + background-position: -136 0; + } + + 100% { + background-position: calc(136px + 100%) 0; + } +} + +.upf-smart-skeleton-effect { + animation: upf-skeleton-content 1.5s ease-in-out alternate infinite; + background-color: var(--color-white); + background-image: linear-gradient( + 150deg, + rgba(255, 255, 255, 0.15) 8.2%, + rgba(247, 213, 250, 0.15) 23.6%, + rgba(83, 94, 252, 0.15) 38.3%, + rgba(237, 33, 255, 0.15) 53.2%, + rgba(255, 255, 255, 0.15) 91.7% + ); + background-size: 136px 100%; + background-repeat: no-repeat; + border-radius: var(--border-radius-xs); + display: inline-block; + line-height: 1; +} diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index d8bac4858..47fef426c 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -151,4 +151,31 @@ +
+
+ Smart skeleton +
+
+
+ + +
+
+ +
+
+ +
+
+
+ \ No newline at end of file diff --git a/tests/integration/components/o-s-s/smart/skeleton-test.ts b/tests/integration/components/o-s-s/smart/skeleton-test.ts new file mode 100644 index 000000000..ad81a1ec4 --- /dev/null +++ b/tests/integration/components/o-s-s/smart/skeleton-test.ts @@ -0,0 +1,155 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, find, findAll, setupOnerror } from '@ember/test-helpers'; + +module('Integration | Component | o-s-s/smart/skeleton', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs``); + assert.dom('.upf-smart-skeleton-effect').exists(); + }); + + module('@height parameters', () => { + test('Default height is 10px', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-skeleton-effect').hasStyle({ height: '10px' }); + }); + + test('The component flat height should correspond to the parameter value', async function (assert) { + this.height = 400; + + await render(hbs``); + + assert.dom('.upf-smart-skeleton-effect').hasStyle({ height: '400px' }); + }); + + test('The component percentage height should correspond to the parameter value', async function (assert) { + this.height = '100%'; + await render(hbs`
`); + assert.dom('.upf-smart-skeleton-effect').hasStyle({ height: '250px' }); + }); + }); + + module('@width parameters', () => { + test('Default width is 36px', async function (assert) { + await render(hbs``); + + assert.dom('.upf-smart-skeleton-effect').hasStyle({ width: '36px' }); + }); + + test('The component flat width should correspond to the parameter value', async function (assert) { + this.width = 400; + + await render(hbs``); + + assert.dom('.upf-smart-skeleton-effect').hasStyle({ width: '400px' }); + }); + + test('The component percentage width should correspond to the parameter value', async function (assert) { + this.width = '100%'; + await render(hbs`
`); + assert.dom('.upf-smart-skeleton-effect').hasStyle({ width: '250px' }); + }); + }); + + module('@gap parameters', () => { + test('Default gap is applied correctly', async function (assert) { + await render(hbs``); + + assert.dom('.fx-1').hasClass('fx-gap-px-9'); + }); + + test('The component gap corresponds to its parameter value', async function (assert) { + this.gap = 12; + + await render(hbs``); + + assert.dom('.fx-1').hasClass('fx-gap-px-12'); + }); + }); + + module('@multiple parameters', () => { + test('Default has one skeleton effect', async function (assert) { + await render(hbs``); + + let items = findAll('.upf-smart-skeleton-effect'); + + assert.ok(items.length === 1); + }); + + test('The content has multiple skeleton effect', async function (assert) { + this.multiple = 4; + + await render(hbs``); + + let items = findAll('.upf-smart-skeleton-effect'); + + assert.ok(items.length === 4); + }); + }); + + module('@randomize parameters', (hooks) => { + hooks.beforeEach(function () { + this.multiple = 4; + this.width = 200; + }); + + test('Default randomize is falsy', async function (assert) { + await render(hbs``); + + let item = find('.upf-smart-skeleton-effect') as HTMLElement; + + assert.ok(this.width == item?.offsetWidth); + }); + + test('Randomize width is within a 15% range', async function (assert) { + await render(hbs``); + + let item = find('.upf-smart-skeleton-effect') as HTMLElement; + + assert.ok(item.offsetWidth <= 230 && item.offsetWidth >= 170); + }); + }); + + module('@direction parameters', () => { + test('Default value is row', async function (assert) { + await render(hbs``); + + assert.dom('.fx-1').hasClass(`fx-row`); + }); + + test('Value is column if specified', async function (assert) { + await render(hbs``); + + assert.dom('.fx-1').hasClass(`fx-col`); + }); + }); + + module('Extra attributes', () => { + test('Passing an extra class applies it to the component', async function (assert) { + await render(hbs``); + assert.dom('.upf-smart-skeleton-effect.my-extra-class').exists(); + }); + + test('Passing a data-control-name applies it to the component', async function (assert) { + await render(hbs``); + let inputWrapper: Element | null = find('.upf-smart-skeleton-effect'); + assert.equal(inputWrapper?.getAttribute('data-control-name'), 'layout-sidebar'); + }); + }); + + module('Error management', () => { + test('It throws an error if @direction is provided and does not match required values', async function (assert) { + setupOnerror((err: any) => { + assert.equal( + err.message, + 'Assertion Failed: [component][OSS::Smart::Skeleton] The @direction argument should be a value of row,column,col' + ); + }); + await render(hbs``); + }); + }); +}); From a835a8f518a5fb4f00d9b8c091491de81123dcf8 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Mon, 23 Jun 2025 10:56:12 +0200 Subject: [PATCH 08/76] [WIP] Smart immersive input component: Add new component & resize based on content --- addon/components/o-s-s/input-container.ts | 4 +- .../o-s-s/smart/immersive/input.hbs | 31 + .../o-s-s/smart/immersive/input.stories.js | 106 +++ .../components/o-s-s/smart/immersive/input.ts | 54 ++ app/components/o-s-s/smart/immersive/input.js | 1 + .../animations/smart-rotating-gradient.less | 613 ++++++++++++++++-- app/styles/core/_smart.less | 155 +++++ tests/dummy/app/controllers/smart.ts | 13 + tests/dummy/app/templates/smart.hbs | 12 + .../o-s-s/smart/immersive/input-test.ts | 26 + 10 files changed, 951 insertions(+), 64 deletions(-) create mode 100644 addon/components/o-s-s/smart/immersive/input.hbs create mode 100644 addon/components/o-s-s/smart/immersive/input.stories.js create mode 100644 addon/components/o-s-s/smart/immersive/input.ts create mode 100644 app/components/o-s-s/smart/immersive/input.js create mode 100644 tests/integration/components/o-s-s/smart/immersive/input-test.ts diff --git a/addon/components/o-s-s/input-container.ts b/addon/components/o-s-s/input-container.ts index 7e42cc3e5..dc5baf75c 100644 --- a/addon/components/o-s-s/input-container.ts +++ b/addon/components/o-s-s/input-container.ts @@ -7,7 +7,7 @@ export type FeedbackMessage = { value: string; }; -interface OSSInputContainerArgs { +export interface OSSInputContainerArgs { value?: string; disabled?: boolean; feedbackMessage?: FeedbackMessage; @@ -21,7 +21,7 @@ interface OSSInputContainerArgs { export const AutocompleteValues = ['on', 'off']; -export default class OSSInputContainer extends Component { +export default class OSSInputContainer extends Component { get feedbackMessage(): FeedbackMessage | undefined { if (this.args.feedbackMessage && ['error', 'warning', 'success'].includes(this.args.feedbackMessage.type)) { return this.args.feedbackMessage; diff --git a/addon/components/o-s-s/smart/immersive/input.hbs b/addon/components/o-s-s/smart/immersive/input.hbs new file mode 100644 index 000000000..6256bcddd --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/input.hbs @@ -0,0 +1,31 @@ + + <:input> + {{#if @loading}} +
+ {{@placeholder}} +
+ {{else}} +
+ {{or @value @placeholder}} + +
+ + {{/if}} + +
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/immersive/input.stories.js b/addon/components/o-s-s/smart/immersive/input.stories.js new file mode 100644 index 000000000..5b0315bd6 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/input.stories.js @@ -0,0 +1,106 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Components/Smart::Immersive::Input', + component: 'input', + argTypes: { + value: { + description: 'Value of the input', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + type: { + description: 'The input type', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'text' } + }, + control: { type: 'text' } + }, + disabled: { + description: 'Disable the default input (when not passing an input named block)', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + placeholder: { + description: 'Placeholder of the input', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + feedbackMessage: { + description: 'A success, warning or error message that will be displayed below the input-group.', + table: { + type: { + summary: '{ type: string, value: string }' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' } + }, + hasError: { + description: + 'Allows setting the error style on the input without showing an error message. Useful for form validation.', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'boolean' } + }, + onChange: { + description: 'Method called every time the input is updated', + table: { + category: 'Actions', + type: { + summary: 'onChange(value: string): void' + } + } + } + }, + parameters: { + docs: { + description: { + component: 'The smart & immersive version of the input component. Configurable.' + } + } + } +}; + +const defaultArgs = { + value: 'John', + disabled: false, + type: undefined, + placeholder: 'this is the placeholder', + errorMessage: undefined, + onChange: action('onChange') +}; + +const DefaultUsageTemplate = (args) => ({ + template: hbs` + + `, + context: args +}); + +export const BasicUsage = DefaultUsageTemplate.bind({}); +BasicUsage.args = defaultArgs; diff --git a/addon/components/o-s-s/smart/immersive/input.ts b/addon/components/o-s-s/smart/immersive/input.ts new file mode 100644 index 000000000..44f8a0721 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/input.ts @@ -0,0 +1,54 @@ +import { action } from '@ember/object'; +import { isEmpty } from '@ember/utils'; +import { tracked } from '@glimmer/tracking'; +import { runSmartGradientAnimation } from '@upfluence/oss-components/utils/run-smart-gradient-animation'; +import type { OSSInputContainerArgs } from '../../input-container'; +import OSSInputContainer from '../../input-container'; + +interface OSSSmartImmersiveInputComponentSignature extends OSSInputContainerArgs { + value: string; + loading: boolean; +} + +export default class OSSSmartImmersiveInputComponent extends OSSInputContainer { + @tracked declare element: HTMLElement; + // ADD assertions + + get placeholder(): string { + return this.args.placeholder ?? ''; + } + + get computedClasses(): string { + const classes = ['smart-immersive-input-container']; + + if (this.args.value) { + classes.push('smart-immersive-input-container--filled'); + } + if (this.args.hasError) { + classes.push('smart-immersive-input-container--errored'); + } + if (this.feedbackMessage) { + classes.push(`smart-immersive-input-container--${this.feedbackMessage.type}`); + } + return classes.join(' '); + } + + @action + onChange(value: string): void { + if (this.args.onChange) { + this.args.onChange(value); + } + } + + @action + registerElement(element: HTMLElement): void { + this.element = element; + } + + @action + runAnimationOnLoadEnd(): void { + if (this.element && this.args.loading === false && !isEmpty(this.args.value)) { + runSmartGradientAnimation(this.element); + } + } +} diff --git a/app/components/o-s-s/smart/immersive/input.js b/app/components/o-s-s/smart/immersive/input.js new file mode 100644 index 000000000..24cf5582a --- /dev/null +++ b/app/components/o-s-s/smart/immersive/input.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/immersive/input'; diff --git a/app/styles/animations/smart-rotating-gradient.less b/app/styles/animations/smart-rotating-gradient.less index 901a18a53..1216854c7 100644 --- a/app/styles/animations/smart-rotating-gradient.less +++ b/app/styles/animations/smart-rotating-gradient.less @@ -1,6 +1,7 @@ .smart-rotating-gradient { position: relative; z-index: 0; + pointer-events: none; &::after { content: ''; @@ -9,7 +10,15 @@ top: 50%; width: calc(100% + 4px); height: calc(100% + 4px); - background: conic-gradient(#549eff 0deg, #ffffff 17deg, #db00ff 72deg, #727ae5 82deg, #ff158d 130deg, #ffffff 172deg, #549eff 190deg); + background: conic-gradient( + #549eff 0deg, + #ffffff 17deg, + #db00ff 72deg, + #727ae5 82deg, + #ff158d 130deg, + #ffffff 172deg, + #549eff 190deg + ); animation: rotate-gradient 2000ms; transform: translate(-50%, -50%); z-index: -1; @@ -32,239 +41,711 @@ } 21% { - background: conic-gradient(#549eff 0deg, #ffffff 17deg, #db00ff 72deg, #727ae5 82deg, #ff158d 130deg, #ffffff 172deg, #549eff 190deg); + background: conic-gradient( + #549eff 0deg, + #ffffff 17deg, + #db00ff 72deg, + #727ae5 82deg, + #ff158d 130deg, + #ffffff 172deg, + #549eff 190deg + ); } 22% { - background: conic-gradient(#549eff 4deg, #ffffff 21deg, #db00ff 76deg, #727ae5 86deg, #ff158d 134deg, #ffffff 176deg, #549eff 194deg); + background: conic-gradient( + #549eff 4deg, + #ffffff 21deg, + #db00ff 76deg, + #727ae5 86deg, + #ff158d 134deg, + #ffffff 176deg, + #549eff 194deg + ); } 23% { - background: conic-gradient(#549eff 7deg, #ffffff 24deg, #db00ff 79deg, #727ae5 89deg, #ff158d 137deg, #ffffff 179deg, #549eff 197deg); + background: conic-gradient( + #549eff 7deg, + #ffffff 24deg, + #db00ff 79deg, + #727ae5 89deg, + #ff158d 137deg, + #ffffff 179deg, + #549eff 197deg + ); } 24% { - background: conic-gradient(#549eff 10deg, #ffffff 27deg, #db00ff 82deg, #727ae5 92deg, #ff158d 140deg, #ffffff 182deg, #549eff 200deg); + background: conic-gradient( + #549eff 10deg, + #ffffff 27deg, + #db00ff 82deg, + #727ae5 92deg, + #ff158d 140deg, + #ffffff 182deg, + #549eff 200deg + ); } 25% { - background: conic-gradient(#549eff 13deg, #ffffff 30deg, #db00ff 85deg, #727ae5 95deg, #ff158d 143deg, #ffffff 185deg, #549eff 203deg); + background: conic-gradient( + #549eff 13deg, + #ffffff 30deg, + #db00ff 85deg, + #727ae5 95deg, + #ff158d 143deg, + #ffffff 185deg, + #549eff 203deg + ); } 26% { - background: conic-gradient(#549eff 16deg, #ffffff 33deg, #db00ff 88deg, #727ae5 98deg, #ff158d 146deg, #ffffff 188deg, #549eff 206deg); + background: conic-gradient( + #549eff 16deg, + #ffffff 33deg, + #db00ff 88deg, + #727ae5 98deg, + #ff158d 146deg, + #ffffff 188deg, + #549eff 206deg + ); } 27% { - background: conic-gradient(#549eff 19deg, #ffffff 36deg, #db00ff 91deg, #727ae5 101deg, #ff158d 149deg, #ffffff 191deg, #549eff 209deg); + background: conic-gradient( + #549eff 19deg, + #ffffff 36deg, + #db00ff 91deg, + #727ae5 101deg, + #ff158d 149deg, + #ffffff 191deg, + #549eff 209deg + ); } 28% { - background: conic-gradient(#549eff 22deg, #ffffff 39deg, #db00ff 94deg, #727ae5 104deg, #ff158d 152deg, #ffffff 194deg, #549eff 212deg); + background: conic-gradient( + #549eff 22deg, + #ffffff 39deg, + #db00ff 94deg, + #727ae5 104deg, + #ff158d 152deg, + #ffffff 194deg, + #549eff 212deg + ); } 29% { - background: conic-gradient(#549eff 26deg, #ffffff 43deg, #db00ff 98deg, #727ae5 108deg, #ff158d 156deg, #ffffff 198deg, #549eff 216deg); + background: conic-gradient( + #549eff 26deg, + #ffffff 43deg, + #db00ff 98deg, + #727ae5 108deg, + #ff158d 156deg, + #ffffff 198deg, + #549eff 216deg + ); } 30% { - background: conic-gradient(#549eff 29deg, #ffffff 46deg, #db00ff 101deg, #727ae5 111deg, #ff158d 159deg, #ffffff 201deg, #549eff 219deg); + background: conic-gradient( + #549eff 29deg, + #ffffff 46deg, + #db00ff 101deg, + #727ae5 111deg, + #ff158d 159deg, + #ffffff 201deg, + #549eff 219deg + ); } 31% { - background: conic-gradient(#549eff 32deg, #ffffff 49deg, #db00ff 104deg, #727ae5 114deg, #ff158d 162deg, #ffffff 204deg, #549eff 222deg); + background: conic-gradient( + #549eff 32deg, + #ffffff 49deg, + #db00ff 104deg, + #727ae5 114deg, + #ff158d 162deg, + #ffffff 204deg, + #549eff 222deg + ); } 32% { - background: conic-gradient(#549eff 35deg, #ffffff 52deg, #db00ff 107deg, #727ae5 117deg, #ff158d 165deg, #ffffff 207deg, #549eff 225deg); + background: conic-gradient( + #549eff 35deg, + #ffffff 52deg, + #db00ff 107deg, + #727ae5 117deg, + #ff158d 165deg, + #ffffff 207deg, + #549eff 225deg + ); } 33% { - background: conic-gradient(#549eff 38deg, #ffffff 55deg, #db00ff 110deg, #727ae5 120deg, #ff158d 168deg, #ffffff 210deg, #549eff 228deg); + background: conic-gradient( + #549eff 38deg, + #ffffff 55deg, + #db00ff 110deg, + #727ae5 120deg, + #ff158d 168deg, + #ffffff 210deg, + #549eff 228deg + ); } 34% { - background: conic-gradient(#549eff 40deg, #ffffff 57deg, #db00ff 112deg, #727ae5 122deg, #ff158d 170deg, #ffffff 212deg, #549eff 230deg); + background: conic-gradient( + #549eff 40deg, + #ffffff 57deg, + #db00ff 112deg, + #727ae5 122deg, + #ff158d 170deg, + #ffffff 212deg, + #549eff 230deg + ); } 35% { - background: conic-gradient(#549eff 43deg, #ffffff 60deg, #db00ff 115deg, #727ae5 125deg, #ff158d 173deg, #ffffff 215deg, #549eff 233deg); + background: conic-gradient( + #549eff 43deg, + #ffffff 60deg, + #db00ff 115deg, + #727ae5 125deg, + #ff158d 173deg, + #ffffff 215deg, + #549eff 233deg + ); } 36% { - background: conic-gradient(#549eff 46deg, #ffffff 63deg, #db00ff 118deg, #727ae5 128deg, #ff158d 176deg, #ffffff 218deg, #549eff 236deg); + background: conic-gradient( + #549eff 46deg, + #ffffff 63deg, + #db00ff 118deg, + #727ae5 128deg, + #ff158d 176deg, + #ffffff 218deg, + #549eff 236deg + ); } 37% { - background: conic-gradient(#549eff 49deg, #ffffff 66deg, #db00ff 121deg, #727ae5 131deg, #ff158d 179deg, #ffffff 221deg, #549eff 239deg); + background: conic-gradient( + #549eff 49deg, + #ffffff 66deg, + #db00ff 121deg, + #727ae5 131deg, + #ff158d 179deg, + #ffffff 221deg, + #549eff 239deg + ); } 38% { - background: conic-gradient(#549eff 52deg, #ffffff 69deg, #db00ff 124deg, #727ae5 134deg, #ff158d 182deg, #ffffff 224deg, #549eff 242deg); + background: conic-gradient( + #549eff 52deg, + #ffffff 69deg, + #db00ff 124deg, + #727ae5 134deg, + #ff158d 182deg, + #ffffff 224deg, + #549eff 242deg + ); } 39% { - background: conic-gradient(#549eff 55deg, #ffffff 72deg, #db00ff 127deg, #727ae5 137deg, #ff158d 185deg, #ffffff 227deg, #549eff 245deg); + background: conic-gradient( + #549eff 55deg, + #ffffff 72deg, + #db00ff 127deg, + #727ae5 137deg, + #ff158d 185deg, + #ffffff 227deg, + #549eff 245deg + ); } 40% { - background: conic-gradient(#549eff 58deg, #ffffff 75deg, #db00ff 130deg, #727ae5 140deg, #ff158d 188deg, #ffffff 230deg, #549eff 248deg); + background: conic-gradient( + #549eff 58deg, + #ffffff 75deg, + #db00ff 130deg, + #727ae5 140deg, + #ff158d 188deg, + #ffffff 230deg, + #549eff 248deg + ); } 41% { - background: conic-gradient(#549eff 61deg, #ffffff 78deg, #db00ff 133deg, #727ae5 143deg, #ff158d 191deg, #ffffff 233deg, #549eff 251deg); + background: conic-gradient( + #549eff 61deg, + #ffffff 78deg, + #db00ff 133deg, + #727ae5 143deg, + #ff158d 191deg, + #ffffff 233deg, + #549eff 251deg + ); } 42% { - background: conic-gradient(#549eff 64deg, #ffffff 81deg, #db00ff 136deg, #727ae5 146deg, #ff158d 194deg, #ffffff 236deg, #549eff 254deg); + background: conic-gradient( + #549eff 64deg, + #ffffff 81deg, + #db00ff 136deg, + #727ae5 146deg, + #ff158d 194deg, + #ffffff 236deg, + #549eff 254deg + ); } 43% { - background: conic-gradient(#549eff 67deg, #ffffff 84deg, #db00ff 139deg, #727ae5 149deg, #ff158d 197deg, #ffffff 239deg, #549eff 257deg); + background: conic-gradient( + #549eff 67deg, + #ffffff 84deg, + #db00ff 139deg, + #727ae5 149deg, + #ff158d 197deg, + #ffffff 239deg, + #549eff 257deg + ); } 44% { - background: conic-gradient(#549eff 70deg, #ffffff 87deg, #db00ff 142deg, #727ae5 152deg, #ff158d 200deg, #ffffff 242deg, #549eff 260deg); + background: conic-gradient( + #549eff 70deg, + #ffffff 87deg, + #db00ff 142deg, + #727ae5 152deg, + #ff158d 200deg, + #ffffff 242deg, + #549eff 260deg + ); } 45% { - background: conic-gradient(#549eff 73deg, #ffffff 90deg, #db00ff 145deg, #727ae5 155deg, #ff158d 203deg, #ffffff 245deg, #549eff 263deg); + background: conic-gradient( + #549eff 73deg, + #ffffff 90deg, + #db00ff 145deg, + #727ae5 155deg, + #ff158d 203deg, + #ffffff 245deg, + #549eff 263deg + ); } 46% { - background: conic-gradient(#549eff 76deg, #ffffff 93deg, #db00ff 148deg, #727ae5 158deg, #ff158d 206deg, #ffffff 248deg, #549eff 266deg); + background: conic-gradient( + #549eff 76deg, + #ffffff 93deg, + #db00ff 148deg, + #727ae5 158deg, + #ff158d 206deg, + #ffffff 248deg, + #549eff 266deg + ); } 47% { - background: conic-gradient(#549eff 79deg, #ffffff 96deg, #db00ff 151deg, #727ae5 161deg, #ff158d 209deg, #ffffff 251deg, #549eff 269deg); + background: conic-gradient( + #549eff 79deg, + #ffffff 96deg, + #db00ff 151deg, + #727ae5 161deg, + #ff158d 209deg, + #ffffff 251deg, + #549eff 269deg + ); } 48% { - background: conic-gradient(#549eff 82deg, #ffffff 99deg, #db00ff 154deg, #727ae5 164deg, #ff158d 212deg, #ffffff 254deg, #549eff 272deg); + background: conic-gradient( + #549eff 82deg, + #ffffff 99deg, + #db00ff 154deg, + #727ae5 164deg, + #ff158d 212deg, + #ffffff 254deg, + #549eff 272deg + ); } 49% { - background: conic-gradient(#549eff 85deg, #ffffff 102deg, #db00ff 157deg, #727ae5 167deg, #ff158d 215deg, #ffffff 257deg, #549eff 275deg); + background: conic-gradient( + #549eff 85deg, + #ffffff 102deg, + #db00ff 157deg, + #727ae5 167deg, + #ff158d 215deg, + #ffffff 257deg, + #549eff 275deg + ); } 50% { - background: conic-gradient(#549eff 88deg, #ffffff 105deg, #db00ff 160deg, #727ae5 170deg, #ff158d 218deg, #ffffff 260deg, #549eff 278deg); + background: conic-gradient( + #549eff 88deg, + #ffffff 105deg, + #db00ff 160deg, + #727ae5 170deg, + #ff158d 218deg, + #ffffff 260deg, + #549eff 278deg + ); } 51% { - background: conic-gradient(#549eff 92deg, #ffffff 109deg, #db00ff 164deg, #727ae5 174deg, #ff158d 222deg, #ffffff 264deg, #549eff 282deg); + background: conic-gradient( + #549eff 92deg, + #ffffff 109deg, + #db00ff 164deg, + #727ae5 174deg, + #ff158d 222deg, + #ffffff 264deg, + #549eff 282deg + ); } 52% { - background: conic-gradient(#549eff 95deg, #ffffff 112deg, #db00ff 167deg, #727ae5 177deg, #ff158d 225deg, #ffffff 267deg, #549eff 285deg); + background: conic-gradient( + #549eff 95deg, + #ffffff 112deg, + #db00ff 167deg, + #727ae5 177deg, + #ff158d 225deg, + #ffffff 267deg, + #549eff 285deg + ); } 53% { - background: conic-gradient(#549eff 98deg, #ffffff 115deg, #db00ff 170deg, #727ae5 180deg, #ff158d 228deg, #ffffff 270deg, #549eff 288deg); + background: conic-gradient( + #549eff 98deg, + #ffffff 115deg, + #db00ff 170deg, + #727ae5 180deg, + #ff158d 228deg, + #ffffff 270deg, + #549eff 288deg + ); } 54% { - background: conic-gradient(#549eff 101deg, #ffffff 118deg, #db00ff 173deg, #727ae5 183deg, #ff158d 231deg, #ffffff 273deg, #549eff 291deg); + background: conic-gradient( + #549eff 101deg, + #ffffff 118deg, + #db00ff 173deg, + #727ae5 183deg, + #ff158d 231deg, + #ffffff 273deg, + #549eff 291deg + ); } 55% { - background: conic-gradient(#549eff 104deg, #ffffff 121deg, #db00ff 176deg, #727ae5 186deg, #ff158d 234deg, #ffffff 276deg, #549eff 294deg); + background: conic-gradient( + #549eff 104deg, + #ffffff 121deg, + #db00ff 176deg, + #727ae5 186deg, + #ff158d 234deg, + #ffffff 276deg, + #549eff 294deg + ); } 56% { - background: conic-gradient(#549eff 107deg, #ffffff 124deg, #db00ff 179deg, #727ae5 189deg, #ff158d 237deg, #ffffff 279deg, #549eff 297deg); + background: conic-gradient( + #549eff 107deg, + #ffffff 124deg, + #db00ff 179deg, + #727ae5 189deg, + #ff158d 237deg, + #ffffff 279deg, + #549eff 297deg + ); } 57% { - background: conic-gradient(#549eff 110deg, #ffffff 127deg, #db00ff 182deg, #727ae5 192deg, #ff158d 240deg, #ffffff 282deg, #549eff 300deg); + background: conic-gradient( + #549eff 110deg, + #ffffff 127deg, + #db00ff 182deg, + #727ae5 192deg, + #ff158d 240deg, + #ffffff 282deg, + #549eff 300deg + ); } 58% { - background: conic-gradient(#549eff 113deg, #ffffff 130deg, #db00ff 185deg, #727ae5 195deg, #ff158d 243deg, #ffffff 285deg, #549eff 303deg); + background: conic-gradient( + #549eff 113deg, + #ffffff 130deg, + #db00ff 185deg, + #727ae5 195deg, + #ff158d 243deg, + #ffffff 285deg, + #549eff 303deg + ); } 59% { - background: conic-gradient(#549eff 116deg, #ffffff 133deg, #db00ff 188deg, #727ae5 198deg, #ff158d 246deg, #ffffff 288deg, #549eff 306deg); + background: conic-gradient( + #549eff 116deg, + #ffffff 133deg, + #db00ff 188deg, + #727ae5 198deg, + #ff158d 246deg, + #ffffff 288deg, + #549eff 306deg + ); } 60% { - background: conic-gradient(#549eff 119deg, #ffffff 136deg, #db00ff 191deg, #727ae5 201deg, #ff158d 249deg, #ffffff 291deg, #549eff 309deg); + background: conic-gradient( + #549eff 119deg, + #ffffff 136deg, + #db00ff 191deg, + #727ae5 201deg, + #ff158d 249deg, + #ffffff 291deg, + #549eff 309deg + ); } 61% { - background: conic-gradient(#549eff 122deg, #ffffff 139deg, #db00ff 194deg, #727ae5 204deg, #ff158d 252deg, #ffffff 294deg, #549eff 312deg); + background: conic-gradient( + #549eff 122deg, + #ffffff 139deg, + #db00ff 194deg, + #727ae5 204deg, + #ff158d 252deg, + #ffffff 294deg, + #549eff 312deg + ); } 62% { - background: conic-gradient(#549eff 125deg, #ffffff 142deg, #db00ff 197deg, #727ae5 207deg, #ff158d 255deg, #ffffff 297deg, #549eff 315deg); + background: conic-gradient( + #549eff 125deg, + #ffffff 142deg, + #db00ff 197deg, + #727ae5 207deg, + #ff158d 255deg, + #ffffff 297deg, + #549eff 315deg + ); } 63% { - background: conic-gradient(#549eff 128deg, #ffffff 145deg, #db00ff 200deg, #727ae5 210deg, #ff158d 258deg, #ffffff 300deg, #549eff 318deg); + background: conic-gradient( + #549eff 128deg, + #ffffff 145deg, + #db00ff 200deg, + #727ae5 210deg, + #ff158d 258deg, + #ffffff 300deg, + #549eff 318deg + ); } 64% { - background: conic-gradient(#549eff 131deg, #ffffff 148deg, #db00ff 203deg, #727ae5 213deg, #ff158d 261deg, #ffffff 303deg, #549eff 321deg); + background: conic-gradient( + #549eff 131deg, + #ffffff 148deg, + #db00ff 203deg, + #727ae5 213deg, + #ff158d 261deg, + #ffffff 303deg, + #549eff 321deg + ); } 65% { - background: conic-gradient(#549eff 134deg, #ffffff 151deg, #db00ff 206deg, #727ae5 216deg, #ff158d 264deg, #ffffff 306deg, #549eff 324deg); + background: conic-gradient( + #549eff 134deg, + #ffffff 151deg, + #db00ff 206deg, + #727ae5 216deg, + #ff158d 264deg, + #ffffff 306deg, + #549eff 324deg + ); } 66% { - background: conic-gradient(#549eff 137deg, #ffffff 154deg, #db00ff 209deg, #727ae5 219deg, #ff158d 267deg, #ffffff 309deg, #549eff 327deg); + background: conic-gradient( + #549eff 137deg, + #ffffff 154deg, + #db00ff 209deg, + #727ae5 219deg, + #ff158d 267deg, + #ffffff 309deg, + #549eff 327deg + ); } 67% { - background: conic-gradient(#549eff 140deg, #ffffff 157deg, #db00ff 212deg, #727ae5 222deg, #ff158d 270deg, #ffffff 312deg, #549eff 330deg); + background: conic-gradient( + #549eff 140deg, + #ffffff 157deg, + #db00ff 212deg, + #727ae5 222deg, + #ff158d 270deg, + #ffffff 312deg, + #549eff 330deg + ); } 68% { - background: conic-gradient(#549eff 143deg, #ffffff 160deg, #db00ff 215deg, #727ae5 225deg, #ff158d 273deg, #ffffff 315deg, #549eff 333deg); + background: conic-gradient( + #549eff 143deg, + #ffffff 160deg, + #db00ff 215deg, + #727ae5 225deg, + #ff158d 273deg, + #ffffff 315deg, + #549eff 333deg + ); } 69% { - background: conic-gradient(#549eff 146deg, #ffffff 163deg, #db00ff 218deg, #727ae5 228deg, #ff158d 276deg, #ffffff 318deg, #549eff 336deg); + background: conic-gradient( + #549eff 146deg, + #ffffff 163deg, + #db00ff 218deg, + #727ae5 228deg, + #ff158d 276deg, + #ffffff 318deg, + #549eff 336deg + ); } 70% { - background: conic-gradient(#549eff 149deg, #ffffff 166deg, #db00ff 221deg, #727ae5 231deg, #ff158d 279deg, #ffffff 321deg, #549eff 339deg); + background: conic-gradient( + #549eff 149deg, + #ffffff 166deg, + #db00ff 221deg, + #727ae5 231deg, + #ff158d 279deg, + #ffffff 321deg, + #549eff 339deg + ); } 71% { - background: conic-gradient(#549eff 153deg, #ffffff 170deg, #db00ff 225deg, #727ae5 235deg, #ff158d 283deg, #ffffff 325deg, #549eff 343deg); + background: conic-gradient( + #549eff 153deg, + #ffffff 170deg, + #db00ff 225deg, + #727ae5 235deg, + #ff158d 283deg, + #ffffff 325deg, + #549eff 343deg + ); } 72% { - background: conic-gradient(#549eff 156deg, #ffffff 173deg, #db00ff 228deg, #727ae5 238deg, #ff158d 286deg, #ffffff 328deg, #549eff 346deg); + background: conic-gradient( + #549eff 156deg, + #ffffff 173deg, + #db00ff 228deg, + #727ae5 238deg, + #ff158d 286deg, + #ffffff 328deg, + #549eff 346deg + ); } 73% { - background: conic-gradient(#549eff 159deg, #ffffff 176deg, #db00ff 231deg, #727ae5 241deg, #ff158d 289deg, #ffffff 331deg, #549eff 349deg); + background: conic-gradient( + #549eff 159deg, + #ffffff 176deg, + #db00ff 231deg, + #727ae5 241deg, + #ff158d 289deg, + #ffffff 331deg, + #549eff 349deg + ); } 74% { - background: conic-gradient(#549eff 162deg, #ffffff 179deg, #db00ff 234deg, #727ae5 244deg, #ff158d 292deg, #ffffff 334deg, #549eff 352deg); + background: conic-gradient( + #549eff 162deg, + #ffffff 179deg, + #db00ff 234deg, + #727ae5 244deg, + #ff158d 292deg, + #ffffff 334deg, + #549eff 352deg + ); } 75% { - background: conic-gradient(#549eff 165deg, #ffffff 182deg, #db00ff 237deg, #727ae5 247deg, #ff158d 295deg, #ffffff 337deg, #549eff 355deg); + background: conic-gradient( + #549eff 165deg, + #ffffff 182deg, + #db00ff 237deg, + #727ae5 247deg, + #ff158d 295deg, + #ffffff 337deg, + #549eff 355deg + ); } 76% { - background: conic-gradient(#549eff 168deg, #ffffff 185deg, #db00ff 240deg, #727ae5 250deg, #ff158d 298deg, #ffffff 340deg, #549eff 358deg); + background: conic-gradient( + #549eff 168deg, + #ffffff 185deg, + #db00ff 240deg, + #727ae5 250deg, + #ff158d 298deg, + #ffffff 340deg, + #549eff 358deg + ); } 77% { - background: conic-gradient(#549eff 171deg, #ffffff 188deg, #db00ff 243deg, #727ae5 253deg, #ff158d 301deg, #ffffff 343deg, #549eff 361deg); + background: conic-gradient( + #549eff 171deg, + #ffffff 188deg, + #db00ff 243deg, + #727ae5 253deg, + #ff158d 301deg, + #ffffff 343deg, + #549eff 361deg + ); } 78% { - background: conic-gradient(#549eff 174deg, #ffffff 191deg, #db00ff 246deg, #727ae5 256deg, #ff158d 304deg, #ffffff 346deg, #549eff 364deg); + background: conic-gradient( + #549eff 174deg, + #ffffff 191deg, + #db00ff 246deg, + #727ae5 256deg, + #ff158d 304deg, + #ffffff 346deg, + #549eff 364deg + ); } 79% { - background: conic-gradient(#549eff 180deg, #ffffff 197deg, #db00ff 252deg, #727ae5 262deg, #ff158d 310deg, #ffffff 352deg, #549eff 370deg); + background: conic-gradient( + #549eff 180deg, + #ffffff 197deg, + #db00ff 252deg, + #727ae5 262deg, + #ff158d 310deg, + #ffffff 352deg, + #549eff 370deg + ); } 80% { @@ -273,7 +754,15 @@ 100% { opacity: 0.1; - background: conic-gradient(#549eff 180deg, #ffffff 197deg, #db00ff 252deg, #727ae5 262deg, #ff158d 310deg, #ffffff 352deg, #549eff 370deg); + background: conic-gradient( + #549eff 180deg, + #ffffff 197deg, + #db00ff 252deg, + #727ae5 262deg, + #ff158d 310deg, + #ffffff 352deg, + #549eff 370deg + ); visibility: hidden; } -} \ No newline at end of file +} diff --git a/app/styles/core/_smart.less b/app/styles/core/_smart.less index 42f1e5f06..7006d9f24 100644 --- a/app/styles/core/_smart.less +++ b/app/styles/core/_smart.less @@ -1,3 +1,158 @@ :root { --border-radius-xl: 999px; } + +@keyframes rainbow_animation { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0 0%; + } +} + +// add `width: fit-content on .smart-immersive-input-container` +// looks like there is a .33px height issue with the animation + +// still todo: +// - tests +// - dynamic component width + +.smart-immersive-input-container { + width: fit-content; + + &.oss-input-container { + .test, + .loading-placeholder { + height: 32px; + min-width: 50px; + border: 1px dashed var(--color-gray-400); + + &:focus, + &:active, + &:hover { + border: 1px dashed var(--color-gray-600); + background-color: transparent; + box-shadow: none; + } + } + + .upf-input.loading-placeholder { + padding: 0px; + } + + .loading-placeholder { + pointer-events: none; + display: flex; + align-items: center; + width: fit-content; + position: relative; + } + + .rainbow_text_animated { + // .font-weight-semibold; + + background: linear-gradient( + 129deg, + var(--color-gray-400) 19%, + rgba(216, 218, 228, 1) 24%, + rgba(208, 217, 255, 1) 31%, + rgba(250, 198, 255, 1) 42%, + rgba(255, 255, 255, 0.3) 61%, + rgba(208, 217, 255, 0.8) 64%, + rgba(250, 198, 255, 0.7) 69%, + var(--color-gray-400) 75% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: rainbow_animation 4s ease-in-out infinite; + background-size: 600% 100%; + padding: 6px 12px; + } + } + + &.oss-input-container.smart-rotating-gradient { + .test { + border: 1px dashed transparent; + } + } + + &--filled { + &.oss-input-container { + .test { + color: var(--color-primary-400); + border-color: var(--color-primary-400); + + &:focus, + &:active { + border: 1px dashed var(--color-primary-400); + } + + &:hover:not(:focus, :active) { + color: var(--color-primary-300); + border: 1px dashed var(--color-primary-300); + } + } + } + } + &--warning { + &.oss-input-container { + .test { + border-color: var(--color-warning-500); + + &:hover:not(:focus, :active), + &:focus, + &:active { + border: 1px dashed var(--color-warning-500); + } + } + } + } + + &--success { + &.oss-input-container { + .test { + border-color: var(--color-success-500); + + &:hover:not(:focus, :active), + &:focus, + &:active { + border: 1px dashed var(--color-success-500); + } + } + } + } + + &--errored, + &--error { + &.oss-input-container { + .test { + border-color: var(--color-error-500); + + &:hover:not(:focus, :active), + &:focus, + &:active { + border: 1px dashed var(--color-error-500); + } + } + } + } + + &.oss-input-container { + &--errored .yielded-input input:not(:disabled, .disabled):focus, + &--error .yielded-input input:not(:disabled, .disabled):focus, + &--warning .yielded-input input:not(:disabled, .disabled):focus, + &--success .yielded-input input:not(:disabled, .disabled):focus { + box-shadow: none; + } + + &--errored .yielded-input input:not(:disabled, .disabled):hover, + &--error .yielded-input input:not(:disabled, .disabled):hover, + &--warning .yielded-input input:not(:disabled, .disabled):hover, + &--success .yielded-input input:not(:disabled, .disabled):hover { + background: transparent; + } + } +} diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index d85b04e03..fdef99be5 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -16,9 +16,22 @@ export default class Smart extends Controller { } ]; + @tracked declare value: string; + @tracked loading: boolean = false; + @action triggerSelection(value: string): void { console.log('selected toggle value : ', value); this.selectedToggle = value; } + + @action + toggleLoading(): void { + this.loading = !this.loading; + + if (this.loading === false) { + this.value = 'short res'; + // this.value = 'Data loaded from a very smart IA'; + } + } } diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index 47fef426c..fc1db8fef 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -9,6 +9,18 @@
+ {{this.loading}} + + + + + + +
Button
diff --git a/tests/integration/components/o-s-s/smart/immersive/input-test.ts b/tests/integration/components/o-s-s/smart/immersive/input-test.ts new file mode 100644 index 000000000..12aa00e62 --- /dev/null +++ b/tests/integration/components/o-s-s/smart/immersive/input-test.ts @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | o-s-s/smart/immersive/input', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function (val) { ... }); + + await render(hbs``); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom().hasText('template block text'); + }); +}); From d3c299b6f98a84936f405031f485499db0a3b8d6 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Mon, 23 Jun 2025 16:03:04 +0200 Subject: [PATCH 09/76] Smart immersive input component: Add tests & cleanup code --- .../o-s-s/smart/immersive/input.hbs | 15 +-- .../components/o-s-s/smart/immersive/input.ts | 6 +- app/styles/core/_smart.less | 68 ++++++----- tests/dummy/app/controllers/smart.ts | 3 +- tests/dummy/app/templates/smart.hbs | 34 ++++-- .../o-s-s/smart/immersive/input-test.ts | 111 ++++++++++++++++-- 6 files changed, 173 insertions(+), 64 deletions(-) diff --git a/addon/components/o-s-s/smart/immersive/input.hbs b/addon/components/o-s-s/smart/immersive/input.hbs index 6256bcddd..62cbe9def 100644 --- a/addon/components/o-s-s/smart/immersive/input.hbs +++ b/addon/components/o-s-s/smart/immersive/input.hbs @@ -7,25 +7,22 @@ > <:input> {{#if @loading}} -
- {{@placeholder}} +
+ {{@placeholder}}
{{else}} -
- {{or @value @placeholder}} +
+ {{or @value @placeholder}}
- {{/if}} \ No newline at end of file diff --git a/addon/components/o-s-s/smart/immersive/input.ts b/addon/components/o-s-s/smart/immersive/input.ts index 44f8a0721..3da16099c 100644 --- a/addon/components/o-s-s/smart/immersive/input.ts +++ b/addon/components/o-s-s/smart/immersive/input.ts @@ -12,7 +12,6 @@ interface OSSSmartImmersiveInputComponentSignature extends OSSInputContainerArgs export default class OSSSmartImmersiveInputComponent extends OSSInputContainer { @tracked declare element: HTMLElement; - // ADD assertions get placeholder(): string { return this.args.placeholder ?? ''; @@ -34,9 +33,10 @@ export default class OSSSmartImmersiveInputComponent extends OSSInputContainer - {{this.loading}} - - - - - - +
+ Smart input immersive +
+
+ + + + +
+
+
Button
diff --git a/tests/integration/components/o-s-s/smart/immersive/input-test.ts b/tests/integration/components/o-s-s/smart/immersive/input-test.ts index 12aa00e62..add3c8217 100644 --- a/tests/integration/components/o-s-s/smart/immersive/input-test.ts +++ b/tests/integration/components/o-s-s/smart/immersive/input-test.ts @@ -1,26 +1,113 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { render, typeIn } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import sinon from 'sinon'; module('Integration | Component | o-s-s/smart/immersive/input', function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + this.value = 'Jolie mouche'; + this.placeholder = "L'eteint ressort"; + this.loading = false; + this.onChange = sinon.stub(); + }); + test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function (val) { ... }); + await renderComponent(); + + assert.dom('.smart-immersive-input-container').exists(); + }); + + module('value', () => { + test('When a value is filled, it renders a specific styling', async function (assert) { + await renderComponent(); + + assert.dom('.smart-immersive-input-container').hasClass('smart-immersive-input-container--filled'); + }); - await render(hbs``); + test('When a value is empty, it renders a specific styling', async function (assert) { + this.value = ''; + await renderComponent(); - assert.dom().hasText(''); + assert.dom('.smart-immersive-input-container').hasNoClass('smart-immersive-input-container--filled'); + }); + }); + + module('placeholder', () => { + test('When a placeholder is empty, it renders a specific styling', async function (assert) { + this.value = ''; + this.placeholder = ''; + await renderComponent(); + + assert.dom('.smart-immersive-input-container input').hasAttribute('placeholder', ''); + }); + + test('When a placeholder is filled, it renders a specific styling', async function (assert) { + this.value = ''; + await renderComponent(); + + assert.dom('.smart-immersive-input-container input').hasAttribute('placeholder', this.placeholder); + }); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + module('OnChange', () => { + test('When input is updated, it trigger the onChange action', async function (assert) { + await renderComponent(); - assert.dom().hasText('template block text'); + assert.ok(this.onChange.notCalled); + await typeIn('.smart-immersive-input-container input', 'a'); + assert.ok(this.onChange.calledOnceWith('Jolie mouchea')); + }); }); + + module('loading', (hooks) => { + hooks.beforeEach(function () { + this.loading = true; + }); + + test('When input is loading, it display an animated div instead of the input', async function (assert) { + await renderComponent(); + + assert.dom('.smart-immersive-input-container input').doesNotExist(); + assert.dom('.loading-placeholder').exists(); + assert.dom('.loading-placeholder').hasText(this.placeholder); + }); + + test('Once loading is finish, it display an animation once', async function (assert) { + await renderComponent(); + this.set('loading', false); + assert.dom('.smart-immersive-input-container').hasClass('smart-rotating-gradient'); + }); + }); + + module('Dynamic width', () => { + test('Input has a min width of 50px', async function (assert) { + this.value = ''; + this.placeholder = ''; + await renderComponent(); + + const element = document.querySelector('.smart-immersive-input-container') as HTMLElement; + assert.equal(element.offsetWidth, 50); + }); + + test('When input has value or placeholder, the size is based on input content', async function (assert) { + await renderComponent(); + + const element = document.querySelector('.smart-immersive-input-container') as HTMLElement; + assert.equal(element.offsetWidth, 102); + await typeIn('.smart-immersive-input-container input', 'more text'); + assert.equal(element.offsetWidth, 156); + }); + }); + + async function renderComponent(): Promise { + return await render( + hbs`` + ); + } }); From 67cf5afda2bb70444a4cb7bdc4171fc4252c389c Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Mon, 23 Jun 2025 17:41:52 +0200 Subject: [PATCH 10/76] Smart immersive input component: Move css in separated file & rollback unwanted formatting --- .../animations/smart-rotating-gradient.less | 612 ++---------------- app/styles/atoms/smart/immersive-input.less | 168 +++++ app/styles/core/_smart.less | 169 ----- app/styles/oss-components.less | 1 + 4 files changed, 231 insertions(+), 719 deletions(-) create mode 100644 app/styles/atoms/smart/immersive-input.less diff --git a/app/styles/animations/smart-rotating-gradient.less b/app/styles/animations/smart-rotating-gradient.less index 1216854c7..366c049a7 100644 --- a/app/styles/animations/smart-rotating-gradient.less +++ b/app/styles/animations/smart-rotating-gradient.less @@ -10,15 +10,7 @@ top: 50%; width: calc(100% + 4px); height: calc(100% + 4px); - background: conic-gradient( - #549eff 0deg, - #ffffff 17deg, - #db00ff 72deg, - #727ae5 82deg, - #ff158d 130deg, - #ffffff 172deg, - #549eff 190deg - ); + background: conic-gradient(#549eff 0deg, #ffffff 17deg, #db00ff 72deg, #727ae5 82deg, #ff158d 130deg, #ffffff 172deg, #549eff 190deg); animation: rotate-gradient 2000ms; transform: translate(-50%, -50%); z-index: -1; @@ -41,711 +33,239 @@ } 21% { - background: conic-gradient( - #549eff 0deg, - #ffffff 17deg, - #db00ff 72deg, - #727ae5 82deg, - #ff158d 130deg, - #ffffff 172deg, - #549eff 190deg - ); + background: conic-gradient(#549eff 0deg, #ffffff 17deg, #db00ff 72deg, #727ae5 82deg, #ff158d 130deg, #ffffff 172deg, #549eff 190deg); } 22% { - background: conic-gradient( - #549eff 4deg, - #ffffff 21deg, - #db00ff 76deg, - #727ae5 86deg, - #ff158d 134deg, - #ffffff 176deg, - #549eff 194deg - ); + background: conic-gradient(#549eff 4deg, #ffffff 21deg, #db00ff 76deg, #727ae5 86deg, #ff158d 134deg, #ffffff 176deg, #549eff 194deg); } 23% { - background: conic-gradient( - #549eff 7deg, - #ffffff 24deg, - #db00ff 79deg, - #727ae5 89deg, - #ff158d 137deg, - #ffffff 179deg, - #549eff 197deg - ); + background: conic-gradient(#549eff 7deg, #ffffff 24deg, #db00ff 79deg, #727ae5 89deg, #ff158d 137deg, #ffffff 179deg, #549eff 197deg); } 24% { - background: conic-gradient( - #549eff 10deg, - #ffffff 27deg, - #db00ff 82deg, - #727ae5 92deg, - #ff158d 140deg, - #ffffff 182deg, - #549eff 200deg - ); + background: conic-gradient(#549eff 10deg, #ffffff 27deg, #db00ff 82deg, #727ae5 92deg, #ff158d 140deg, #ffffff 182deg, #549eff 200deg); } 25% { - background: conic-gradient( - #549eff 13deg, - #ffffff 30deg, - #db00ff 85deg, - #727ae5 95deg, - #ff158d 143deg, - #ffffff 185deg, - #549eff 203deg - ); + background: conic-gradient(#549eff 13deg, #ffffff 30deg, #db00ff 85deg, #727ae5 95deg, #ff158d 143deg, #ffffff 185deg, #549eff 203deg); } 26% { - background: conic-gradient( - #549eff 16deg, - #ffffff 33deg, - #db00ff 88deg, - #727ae5 98deg, - #ff158d 146deg, - #ffffff 188deg, - #549eff 206deg - ); + background: conic-gradient(#549eff 16deg, #ffffff 33deg, #db00ff 88deg, #727ae5 98deg, #ff158d 146deg, #ffffff 188deg, #549eff 206deg); } 27% { - background: conic-gradient( - #549eff 19deg, - #ffffff 36deg, - #db00ff 91deg, - #727ae5 101deg, - #ff158d 149deg, - #ffffff 191deg, - #549eff 209deg - ); + background: conic-gradient(#549eff 19deg, #ffffff 36deg, #db00ff 91deg, #727ae5 101deg, #ff158d 149deg, #ffffff 191deg, #549eff 209deg); } 28% { - background: conic-gradient( - #549eff 22deg, - #ffffff 39deg, - #db00ff 94deg, - #727ae5 104deg, - #ff158d 152deg, - #ffffff 194deg, - #549eff 212deg - ); + background: conic-gradient(#549eff 22deg, #ffffff 39deg, #db00ff 94deg, #727ae5 104deg, #ff158d 152deg, #ffffff 194deg, #549eff 212deg); } 29% { - background: conic-gradient( - #549eff 26deg, - #ffffff 43deg, - #db00ff 98deg, - #727ae5 108deg, - #ff158d 156deg, - #ffffff 198deg, - #549eff 216deg - ); + background: conic-gradient(#549eff 26deg, #ffffff 43deg, #db00ff 98deg, #727ae5 108deg, #ff158d 156deg, #ffffff 198deg, #549eff 216deg); } 30% { - background: conic-gradient( - #549eff 29deg, - #ffffff 46deg, - #db00ff 101deg, - #727ae5 111deg, - #ff158d 159deg, - #ffffff 201deg, - #549eff 219deg - ); + background: conic-gradient(#549eff 29deg, #ffffff 46deg, #db00ff 101deg, #727ae5 111deg, #ff158d 159deg, #ffffff 201deg, #549eff 219deg); } 31% { - background: conic-gradient( - #549eff 32deg, - #ffffff 49deg, - #db00ff 104deg, - #727ae5 114deg, - #ff158d 162deg, - #ffffff 204deg, - #549eff 222deg - ); + background: conic-gradient(#549eff 32deg, #ffffff 49deg, #db00ff 104deg, #727ae5 114deg, #ff158d 162deg, #ffffff 204deg, #549eff 222deg); } 32% { - background: conic-gradient( - #549eff 35deg, - #ffffff 52deg, - #db00ff 107deg, - #727ae5 117deg, - #ff158d 165deg, - #ffffff 207deg, - #549eff 225deg - ); + background: conic-gradient(#549eff 35deg, #ffffff 52deg, #db00ff 107deg, #727ae5 117deg, #ff158d 165deg, #ffffff 207deg, #549eff 225deg); } 33% { - background: conic-gradient( - #549eff 38deg, - #ffffff 55deg, - #db00ff 110deg, - #727ae5 120deg, - #ff158d 168deg, - #ffffff 210deg, - #549eff 228deg - ); + background: conic-gradient(#549eff 38deg, #ffffff 55deg, #db00ff 110deg, #727ae5 120deg, #ff158d 168deg, #ffffff 210deg, #549eff 228deg); } 34% { - background: conic-gradient( - #549eff 40deg, - #ffffff 57deg, - #db00ff 112deg, - #727ae5 122deg, - #ff158d 170deg, - #ffffff 212deg, - #549eff 230deg - ); + background: conic-gradient(#549eff 40deg, #ffffff 57deg, #db00ff 112deg, #727ae5 122deg, #ff158d 170deg, #ffffff 212deg, #549eff 230deg); } 35% { - background: conic-gradient( - #549eff 43deg, - #ffffff 60deg, - #db00ff 115deg, - #727ae5 125deg, - #ff158d 173deg, - #ffffff 215deg, - #549eff 233deg - ); + background: conic-gradient(#549eff 43deg, #ffffff 60deg, #db00ff 115deg, #727ae5 125deg, #ff158d 173deg, #ffffff 215deg, #549eff 233deg); } 36% { - background: conic-gradient( - #549eff 46deg, - #ffffff 63deg, - #db00ff 118deg, - #727ae5 128deg, - #ff158d 176deg, - #ffffff 218deg, - #549eff 236deg - ); + background: conic-gradient(#549eff 46deg, #ffffff 63deg, #db00ff 118deg, #727ae5 128deg, #ff158d 176deg, #ffffff 218deg, #549eff 236deg); } 37% { - background: conic-gradient( - #549eff 49deg, - #ffffff 66deg, - #db00ff 121deg, - #727ae5 131deg, - #ff158d 179deg, - #ffffff 221deg, - #549eff 239deg - ); + background: conic-gradient(#549eff 49deg, #ffffff 66deg, #db00ff 121deg, #727ae5 131deg, #ff158d 179deg, #ffffff 221deg, #549eff 239deg); } 38% { - background: conic-gradient( - #549eff 52deg, - #ffffff 69deg, - #db00ff 124deg, - #727ae5 134deg, - #ff158d 182deg, - #ffffff 224deg, - #549eff 242deg - ); + background: conic-gradient(#549eff 52deg, #ffffff 69deg, #db00ff 124deg, #727ae5 134deg, #ff158d 182deg, #ffffff 224deg, #549eff 242deg); } 39% { - background: conic-gradient( - #549eff 55deg, - #ffffff 72deg, - #db00ff 127deg, - #727ae5 137deg, - #ff158d 185deg, - #ffffff 227deg, - #549eff 245deg - ); + background: conic-gradient(#549eff 55deg, #ffffff 72deg, #db00ff 127deg, #727ae5 137deg, #ff158d 185deg, #ffffff 227deg, #549eff 245deg); } 40% { - background: conic-gradient( - #549eff 58deg, - #ffffff 75deg, - #db00ff 130deg, - #727ae5 140deg, - #ff158d 188deg, - #ffffff 230deg, - #549eff 248deg - ); + background: conic-gradient(#549eff 58deg, #ffffff 75deg, #db00ff 130deg, #727ae5 140deg, #ff158d 188deg, #ffffff 230deg, #549eff 248deg); } 41% { - background: conic-gradient( - #549eff 61deg, - #ffffff 78deg, - #db00ff 133deg, - #727ae5 143deg, - #ff158d 191deg, - #ffffff 233deg, - #549eff 251deg - ); + background: conic-gradient(#549eff 61deg, #ffffff 78deg, #db00ff 133deg, #727ae5 143deg, #ff158d 191deg, #ffffff 233deg, #549eff 251deg); } 42% { - background: conic-gradient( - #549eff 64deg, - #ffffff 81deg, - #db00ff 136deg, - #727ae5 146deg, - #ff158d 194deg, - #ffffff 236deg, - #549eff 254deg - ); + background: conic-gradient(#549eff 64deg, #ffffff 81deg, #db00ff 136deg, #727ae5 146deg, #ff158d 194deg, #ffffff 236deg, #549eff 254deg); } 43% { - background: conic-gradient( - #549eff 67deg, - #ffffff 84deg, - #db00ff 139deg, - #727ae5 149deg, - #ff158d 197deg, - #ffffff 239deg, - #549eff 257deg - ); + background: conic-gradient(#549eff 67deg, #ffffff 84deg, #db00ff 139deg, #727ae5 149deg, #ff158d 197deg, #ffffff 239deg, #549eff 257deg); } 44% { - background: conic-gradient( - #549eff 70deg, - #ffffff 87deg, - #db00ff 142deg, - #727ae5 152deg, - #ff158d 200deg, - #ffffff 242deg, - #549eff 260deg - ); + background: conic-gradient(#549eff 70deg, #ffffff 87deg, #db00ff 142deg, #727ae5 152deg, #ff158d 200deg, #ffffff 242deg, #549eff 260deg); } 45% { - background: conic-gradient( - #549eff 73deg, - #ffffff 90deg, - #db00ff 145deg, - #727ae5 155deg, - #ff158d 203deg, - #ffffff 245deg, - #549eff 263deg - ); + background: conic-gradient(#549eff 73deg, #ffffff 90deg, #db00ff 145deg, #727ae5 155deg, #ff158d 203deg, #ffffff 245deg, #549eff 263deg); } 46% { - background: conic-gradient( - #549eff 76deg, - #ffffff 93deg, - #db00ff 148deg, - #727ae5 158deg, - #ff158d 206deg, - #ffffff 248deg, - #549eff 266deg - ); + background: conic-gradient(#549eff 76deg, #ffffff 93deg, #db00ff 148deg, #727ae5 158deg, #ff158d 206deg, #ffffff 248deg, #549eff 266deg); } 47% { - background: conic-gradient( - #549eff 79deg, - #ffffff 96deg, - #db00ff 151deg, - #727ae5 161deg, - #ff158d 209deg, - #ffffff 251deg, - #549eff 269deg - ); + background: conic-gradient(#549eff 79deg, #ffffff 96deg, #db00ff 151deg, #727ae5 161deg, #ff158d 209deg, #ffffff 251deg, #549eff 269deg); } 48% { - background: conic-gradient( - #549eff 82deg, - #ffffff 99deg, - #db00ff 154deg, - #727ae5 164deg, - #ff158d 212deg, - #ffffff 254deg, - #549eff 272deg - ); + background: conic-gradient(#549eff 82deg, #ffffff 99deg, #db00ff 154deg, #727ae5 164deg, #ff158d 212deg, #ffffff 254deg, #549eff 272deg); } 49% { - background: conic-gradient( - #549eff 85deg, - #ffffff 102deg, - #db00ff 157deg, - #727ae5 167deg, - #ff158d 215deg, - #ffffff 257deg, - #549eff 275deg - ); + background: conic-gradient(#549eff 85deg, #ffffff 102deg, #db00ff 157deg, #727ae5 167deg, #ff158d 215deg, #ffffff 257deg, #549eff 275deg); } 50% { - background: conic-gradient( - #549eff 88deg, - #ffffff 105deg, - #db00ff 160deg, - #727ae5 170deg, - #ff158d 218deg, - #ffffff 260deg, - #549eff 278deg - ); + background: conic-gradient(#549eff 88deg, #ffffff 105deg, #db00ff 160deg, #727ae5 170deg, #ff158d 218deg, #ffffff 260deg, #549eff 278deg); } 51% { - background: conic-gradient( - #549eff 92deg, - #ffffff 109deg, - #db00ff 164deg, - #727ae5 174deg, - #ff158d 222deg, - #ffffff 264deg, - #549eff 282deg - ); + background: conic-gradient(#549eff 92deg, #ffffff 109deg, #db00ff 164deg, #727ae5 174deg, #ff158d 222deg, #ffffff 264deg, #549eff 282deg); } 52% { - background: conic-gradient( - #549eff 95deg, - #ffffff 112deg, - #db00ff 167deg, - #727ae5 177deg, - #ff158d 225deg, - #ffffff 267deg, - #549eff 285deg - ); + background: conic-gradient(#549eff 95deg, #ffffff 112deg, #db00ff 167deg, #727ae5 177deg, #ff158d 225deg, #ffffff 267deg, #549eff 285deg); } 53% { - background: conic-gradient( - #549eff 98deg, - #ffffff 115deg, - #db00ff 170deg, - #727ae5 180deg, - #ff158d 228deg, - #ffffff 270deg, - #549eff 288deg - ); + background: conic-gradient(#549eff 98deg, #ffffff 115deg, #db00ff 170deg, #727ae5 180deg, #ff158d 228deg, #ffffff 270deg, #549eff 288deg); } 54% { - background: conic-gradient( - #549eff 101deg, - #ffffff 118deg, - #db00ff 173deg, - #727ae5 183deg, - #ff158d 231deg, - #ffffff 273deg, - #549eff 291deg - ); + background: conic-gradient(#549eff 101deg, #ffffff 118deg, #db00ff 173deg, #727ae5 183deg, #ff158d 231deg, #ffffff 273deg, #549eff 291deg); } 55% { - background: conic-gradient( - #549eff 104deg, - #ffffff 121deg, - #db00ff 176deg, - #727ae5 186deg, - #ff158d 234deg, - #ffffff 276deg, - #549eff 294deg - ); + background: conic-gradient(#549eff 104deg, #ffffff 121deg, #db00ff 176deg, #727ae5 186deg, #ff158d 234deg, #ffffff 276deg, #549eff 294deg); } 56% { - background: conic-gradient( - #549eff 107deg, - #ffffff 124deg, - #db00ff 179deg, - #727ae5 189deg, - #ff158d 237deg, - #ffffff 279deg, - #549eff 297deg - ); + background: conic-gradient(#549eff 107deg, #ffffff 124deg, #db00ff 179deg, #727ae5 189deg, #ff158d 237deg, #ffffff 279deg, #549eff 297deg); } 57% { - background: conic-gradient( - #549eff 110deg, - #ffffff 127deg, - #db00ff 182deg, - #727ae5 192deg, - #ff158d 240deg, - #ffffff 282deg, - #549eff 300deg - ); + background: conic-gradient(#549eff 110deg, #ffffff 127deg, #db00ff 182deg, #727ae5 192deg, #ff158d 240deg, #ffffff 282deg, #549eff 300deg); } 58% { - background: conic-gradient( - #549eff 113deg, - #ffffff 130deg, - #db00ff 185deg, - #727ae5 195deg, - #ff158d 243deg, - #ffffff 285deg, - #549eff 303deg - ); + background: conic-gradient(#549eff 113deg, #ffffff 130deg, #db00ff 185deg, #727ae5 195deg, #ff158d 243deg, #ffffff 285deg, #549eff 303deg); } 59% { - background: conic-gradient( - #549eff 116deg, - #ffffff 133deg, - #db00ff 188deg, - #727ae5 198deg, - #ff158d 246deg, - #ffffff 288deg, - #549eff 306deg - ); + background: conic-gradient(#549eff 116deg, #ffffff 133deg, #db00ff 188deg, #727ae5 198deg, #ff158d 246deg, #ffffff 288deg, #549eff 306deg); } 60% { - background: conic-gradient( - #549eff 119deg, - #ffffff 136deg, - #db00ff 191deg, - #727ae5 201deg, - #ff158d 249deg, - #ffffff 291deg, - #549eff 309deg - ); + background: conic-gradient(#549eff 119deg, #ffffff 136deg, #db00ff 191deg, #727ae5 201deg, #ff158d 249deg, #ffffff 291deg, #549eff 309deg); } 61% { - background: conic-gradient( - #549eff 122deg, - #ffffff 139deg, - #db00ff 194deg, - #727ae5 204deg, - #ff158d 252deg, - #ffffff 294deg, - #549eff 312deg - ); + background: conic-gradient(#549eff 122deg, #ffffff 139deg, #db00ff 194deg, #727ae5 204deg, #ff158d 252deg, #ffffff 294deg, #549eff 312deg); } 62% { - background: conic-gradient( - #549eff 125deg, - #ffffff 142deg, - #db00ff 197deg, - #727ae5 207deg, - #ff158d 255deg, - #ffffff 297deg, - #549eff 315deg - ); + background: conic-gradient(#549eff 125deg, #ffffff 142deg, #db00ff 197deg, #727ae5 207deg, #ff158d 255deg, #ffffff 297deg, #549eff 315deg); } 63% { - background: conic-gradient( - #549eff 128deg, - #ffffff 145deg, - #db00ff 200deg, - #727ae5 210deg, - #ff158d 258deg, - #ffffff 300deg, - #549eff 318deg - ); + background: conic-gradient(#549eff 128deg, #ffffff 145deg, #db00ff 200deg, #727ae5 210deg, #ff158d 258deg, #ffffff 300deg, #549eff 318deg); } 64% { - background: conic-gradient( - #549eff 131deg, - #ffffff 148deg, - #db00ff 203deg, - #727ae5 213deg, - #ff158d 261deg, - #ffffff 303deg, - #549eff 321deg - ); + background: conic-gradient(#549eff 131deg, #ffffff 148deg, #db00ff 203deg, #727ae5 213deg, #ff158d 261deg, #ffffff 303deg, #549eff 321deg); } 65% { - background: conic-gradient( - #549eff 134deg, - #ffffff 151deg, - #db00ff 206deg, - #727ae5 216deg, - #ff158d 264deg, - #ffffff 306deg, - #549eff 324deg - ); + background: conic-gradient(#549eff 134deg, #ffffff 151deg, #db00ff 206deg, #727ae5 216deg, #ff158d 264deg, #ffffff 306deg, #549eff 324deg); } 66% { - background: conic-gradient( - #549eff 137deg, - #ffffff 154deg, - #db00ff 209deg, - #727ae5 219deg, - #ff158d 267deg, - #ffffff 309deg, - #549eff 327deg - ); + background: conic-gradient(#549eff 137deg, #ffffff 154deg, #db00ff 209deg, #727ae5 219deg, #ff158d 267deg, #ffffff 309deg, #549eff 327deg); } 67% { - background: conic-gradient( - #549eff 140deg, - #ffffff 157deg, - #db00ff 212deg, - #727ae5 222deg, - #ff158d 270deg, - #ffffff 312deg, - #549eff 330deg - ); + background: conic-gradient(#549eff 140deg, #ffffff 157deg, #db00ff 212deg, #727ae5 222deg, #ff158d 270deg, #ffffff 312deg, #549eff 330deg); } 68% { - background: conic-gradient( - #549eff 143deg, - #ffffff 160deg, - #db00ff 215deg, - #727ae5 225deg, - #ff158d 273deg, - #ffffff 315deg, - #549eff 333deg - ); + background: conic-gradient(#549eff 143deg, #ffffff 160deg, #db00ff 215deg, #727ae5 225deg, #ff158d 273deg, #ffffff 315deg, #549eff 333deg); } 69% { - background: conic-gradient( - #549eff 146deg, - #ffffff 163deg, - #db00ff 218deg, - #727ae5 228deg, - #ff158d 276deg, - #ffffff 318deg, - #549eff 336deg - ); + background: conic-gradient(#549eff 146deg, #ffffff 163deg, #db00ff 218deg, #727ae5 228deg, #ff158d 276deg, #ffffff 318deg, #549eff 336deg); } 70% { - background: conic-gradient( - #549eff 149deg, - #ffffff 166deg, - #db00ff 221deg, - #727ae5 231deg, - #ff158d 279deg, - #ffffff 321deg, - #549eff 339deg - ); + background: conic-gradient(#549eff 149deg, #ffffff 166deg, #db00ff 221deg, #727ae5 231deg, #ff158d 279deg, #ffffff 321deg, #549eff 339deg); } 71% { - background: conic-gradient( - #549eff 153deg, - #ffffff 170deg, - #db00ff 225deg, - #727ae5 235deg, - #ff158d 283deg, - #ffffff 325deg, - #549eff 343deg - ); + background: conic-gradient(#549eff 153deg, #ffffff 170deg, #db00ff 225deg, #727ae5 235deg, #ff158d 283deg, #ffffff 325deg, #549eff 343deg); } 72% { - background: conic-gradient( - #549eff 156deg, - #ffffff 173deg, - #db00ff 228deg, - #727ae5 238deg, - #ff158d 286deg, - #ffffff 328deg, - #549eff 346deg - ); + background: conic-gradient(#549eff 156deg, #ffffff 173deg, #db00ff 228deg, #727ae5 238deg, #ff158d 286deg, #ffffff 328deg, #549eff 346deg); } 73% { - background: conic-gradient( - #549eff 159deg, - #ffffff 176deg, - #db00ff 231deg, - #727ae5 241deg, - #ff158d 289deg, - #ffffff 331deg, - #549eff 349deg - ); + background: conic-gradient(#549eff 159deg, #ffffff 176deg, #db00ff 231deg, #727ae5 241deg, #ff158d 289deg, #ffffff 331deg, #549eff 349deg); } 74% { - background: conic-gradient( - #549eff 162deg, - #ffffff 179deg, - #db00ff 234deg, - #727ae5 244deg, - #ff158d 292deg, - #ffffff 334deg, - #549eff 352deg - ); + background: conic-gradient(#549eff 162deg, #ffffff 179deg, #db00ff 234deg, #727ae5 244deg, #ff158d 292deg, #ffffff 334deg, #549eff 352deg); } 75% { - background: conic-gradient( - #549eff 165deg, - #ffffff 182deg, - #db00ff 237deg, - #727ae5 247deg, - #ff158d 295deg, - #ffffff 337deg, - #549eff 355deg - ); + background: conic-gradient(#549eff 165deg, #ffffff 182deg, #db00ff 237deg, #727ae5 247deg, #ff158d 295deg, #ffffff 337deg, #549eff 355deg); } 76% { - background: conic-gradient( - #549eff 168deg, - #ffffff 185deg, - #db00ff 240deg, - #727ae5 250deg, - #ff158d 298deg, - #ffffff 340deg, - #549eff 358deg - ); + background: conic-gradient(#549eff 168deg, #ffffff 185deg, #db00ff 240deg, #727ae5 250deg, #ff158d 298deg, #ffffff 340deg, #549eff 358deg); } 77% { - background: conic-gradient( - #549eff 171deg, - #ffffff 188deg, - #db00ff 243deg, - #727ae5 253deg, - #ff158d 301deg, - #ffffff 343deg, - #549eff 361deg - ); + background: conic-gradient(#549eff 171deg, #ffffff 188deg, #db00ff 243deg, #727ae5 253deg, #ff158d 301deg, #ffffff 343deg, #549eff 361deg); } 78% { - background: conic-gradient( - #549eff 174deg, - #ffffff 191deg, - #db00ff 246deg, - #727ae5 256deg, - #ff158d 304deg, - #ffffff 346deg, - #549eff 364deg - ); + background: conic-gradient(#549eff 174deg, #ffffff 191deg, #db00ff 246deg, #727ae5 256deg, #ff158d 304deg, #ffffff 346deg, #549eff 364deg); } 79% { - background: conic-gradient( - #549eff 180deg, - #ffffff 197deg, - #db00ff 252deg, - #727ae5 262deg, - #ff158d 310deg, - #ffffff 352deg, - #549eff 370deg - ); + background: conic-gradient(#549eff 180deg, #ffffff 197deg, #db00ff 252deg, #727ae5 262deg, #ff158d 310deg, #ffffff 352deg, #549eff 370deg); } 80% { @@ -754,15 +274,7 @@ 100% { opacity: 0.1; - background: conic-gradient( - #549eff 180deg, - #ffffff 197deg, - #db00ff 252deg, - #727ae5 262deg, - #ff158d 310deg, - #ffffff 352deg, - #549eff 370deg - ); + background: conic-gradient(#549eff 180deg, #ffffff 197deg, #db00ff 252deg, #727ae5 262deg, #ff158d 310deg, #ffffff 352deg, #549eff 370deg); visibility: hidden; } -} +} \ No newline at end of file diff --git a/app/styles/atoms/smart/immersive-input.less b/app/styles/atoms/smart/immersive-input.less new file mode 100644 index 000000000..109534adc --- /dev/null +++ b/app/styles/atoms/smart/immersive-input.less @@ -0,0 +1,168 @@ +.smart-immersive-input-container { + width: fit-content; + + .smart-immersive-input-sizer { + width: min-content; + } + + .smart-immersive-span-placeholder { + height: 0px; + display: block; + padding: 0 calc(var(--spacing-px-12) + 1px); + visibility: hidden; + width: fit-content; + white-space: pre; + + &:has(+ input[type='number']) { + padding: 0 calc(var(--spacing-px-24) + 1px); + } + } + + &.oss-input-container { + .smart-immersive--input, + .loading-placeholder { + height: 32px; + min-width: 50px; + border: 1px dashed var(--color-gray-400); + + &:focus, + &:active, + &:hover { + border: 1px dashed var(--color-gray-600); + background-color: transparent; + box-shadow: none; + } + } + + .smart-immersive--input[type='number']::-webkit-outer-spin-button, + .smart-immersive--input[type='number']::-webkit-inner-spin-button { + color: red; + background-color: rebeccapurple; + } + .upf-input.loading-placeholder { + padding: 0px; + } + + .loading-placeholder { + pointer-events: none; + display: flex; + align-items: center; + width: fit-content; + position: relative; + } + + .smart_text_animated { + background: var(--color-gray-400); + background: linear-gradient( + 130deg, + var(--color-gray-400) 18%, + var(--color-gray-300) 25%, + rgba(250, 198, 255, 1) 56%, + var(--color-primary-100) 62%, + var(--color-white) 66%, + rgba(250, 198, 255, 0.58) 68%, + var(--color-gray-300) 73%, + var(--color-gray-400) 85% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: smart_loading_text_animation 3.5s ease-in-out infinite; + background-size: 600% 100%; + padding: 6px 12px; + } + } + + &.oss-input-container.smart-rotating-gradient { + .smart-immersive--input { + border: 1px dashed transparent; + } + } + + &--filled { + &.oss-input-container { + .smart-immersive--input { + color: var(--color-primary-400); + border-color: var(--color-primary-400); + + &:focus, + &:active { + border: 1px dashed var(--color-primary-400); + } + + &:hover:not(:focus, :active) { + color: var(--color-primary-300); + border: 1px dashed var(--color-primary-300); + } + } + } + } + &--warning { + &.oss-input-container { + .smart-immersive--input { + border-color: var(--color-warning-500); + + &:hover:not(:focus, :active), + &:focus, + &:active { + border: 1px dashed var(--color-warning-500); + } + } + } + } + + &--success { + &.oss-input-container { + .smart-immersive--input { + border-color: var(--color-success-500); + + &:hover:not(:focus, :active), + &:focus, + &:active { + border: 1px dashed var(--color-success-500); + } + } + } + } + + &--errored, + &--error { + &.oss-input-container { + .smart-immersive--input { + border-color: var(--color-error-500); + + &:hover:not(:focus, :active), + &:focus, + &:active { + border: 1px dashed var(--color-error-500); + } + } + } + } + + &.oss-input-container { + &--errored .yielded-input input:not(:disabled, .disabled):focus, + &--error .yielded-input input:not(:disabled, .disabled):focus, + &--warning .yielded-input input:not(:disabled, .disabled):focus, + &--success .yielded-input input:not(:disabled, .disabled):focus { + box-shadow: none; + } + + &--errored .yielded-input input:not(:disabled, .disabled):hover, + &--error .yielded-input input:not(:disabled, .disabled):hover, + &--warning .yielded-input input:not(:disabled, .disabled):hover, + &--success .yielded-input input:not(:disabled, .disabled):hover { + background: transparent; + } + } +} + +@keyframes smart_loading_text_animation { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0 0%; + } +} diff --git a/app/styles/core/_smart.less b/app/styles/core/_smart.less index 3714af0c8..42f1e5f06 100644 --- a/app/styles/core/_smart.less +++ b/app/styles/core/_smart.less @@ -1,172 +1,3 @@ :root { --border-radius-xl: 999px; } - -@keyframes smart_loading_text_animation { - 0% { - background-position: 100% 0; - } - - 100% { - background-position: 0 0%; - } -} - -.smart-immersive-input-container { - width: fit-content; - - .smart-immersive-input-sizer { - width: min-content; - } - - .smart-immersive-span-placeholder { - height: 0px; - display: block; - padding: 0 calc(var(--spacing-px-12) + 1px); - visibility: hidden; - width: fit-content; - white-space: pre; - - &:has(+ input[type='number']) { - padding: 0 calc(var(--spacing-px-24) + 1px); - } - } - - &.oss-input-container { - .smart-immersive--input, - .loading-placeholder { - height: 32px; - min-width: 50px; - border: 1px dashed var(--color-gray-400); - - &:focus, - &:active, - &:hover { - border: 1px dashed var(--color-gray-600); - background-color: transparent; - box-shadow: none; - } - } - - .smart-immersive--input[type='number']::-webkit-outer-spin-button, - .smart-immersive--input[type='number']::-webkit-inner-spin-button { - color: red; - background-color: rebeccapurple; - } - .upf-input.loading-placeholder { - padding: 0px; - } - - .loading-placeholder { - pointer-events: none; - display: flex; - align-items: center; - width: fit-content; - position: relative; - } - - .smart_text_animated { - background: var(--color-gray-400); - background: linear-gradient( - 130deg, - var(--color-gray-400) 18%, - var(--color-gray-300) 25%, - rgba(250, 198, 255, 1) 56%, - var(--color-primary-100) 62%, - var(--color-white) 66%, - rgba(250, 198, 255, 0.58) 68%, - var(--color-gray-300) 73%, - var(--color-gray-400) 85% - ); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - animation: smart_loading_text_animation 3.5s ease-in-out infinite; - background-size: 600% 100%; - padding: 6px 12px; - } - } - - &.oss-input-container.smart-rotating-gradient { - .smart-immersive--input { - border: 1px dashed transparent; - } - } - - &--filled { - &.oss-input-container { - .smart-immersive--input { - color: var(--color-primary-400); - border-color: var(--color-primary-400); - - &:focus, - &:active { - border: 1px dashed var(--color-primary-400); - } - - &:hover:not(:focus, :active) { - color: var(--color-primary-300); - border: 1px dashed var(--color-primary-300); - } - } - } - } - &--warning { - &.oss-input-container { - .smart-immersive--input { - border-color: var(--color-warning-500); - - &:hover:not(:focus, :active), - &:focus, - &:active { - border: 1px dashed var(--color-warning-500); - } - } - } - } - - &--success { - &.oss-input-container { - .smart-immersive--input { - border-color: var(--color-success-500); - - &:hover:not(:focus, :active), - &:focus, - &:active { - border: 1px dashed var(--color-success-500); - } - } - } - } - - &--errored, - &--error { - &.oss-input-container { - .smart-immersive--input { - border-color: var(--color-error-500); - - &:hover:not(:focus, :active), - &:focus, - &:active { - border: 1px dashed var(--color-error-500); - } - } - } - } - - &.oss-input-container { - &--errored .yielded-input input:not(:disabled, .disabled):focus, - &--error .yielded-input input:not(:disabled, .disabled):focus, - &--warning .yielded-input input:not(:disabled, .disabled):focus, - &--success .yielded-input input:not(:disabled, .disabled):focus { - box-shadow: none; - } - - &--errored .yielded-input input:not(:disabled, .disabled):hover, - &--error .yielded-input input:not(:disabled, .disabled):hover, - &--warning .yielded-input input:not(:disabled, .disabled):hover, - &--success .yielded-input input:not(:disabled, .disabled):hover { - background: transparent; - } - } -} diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less index 8f5d1b89e..f82c4d04f 100644 --- a/app/styles/oss-components.less +++ b/app/styles/oss-components.less @@ -46,6 +46,7 @@ @import 'atoms/social-post-badge'; @import 'atoms/pulsating-dot'; @import 'atoms/pill'; +@import 'atoms/smart/immersive-input'; @import 'atoms/smart/pill'; @import 'molecules/progress-bar'; @import 'molecules/select'; From 3627464b245ba50eff57e13c7e7c0ccb5f3b5897 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Tue, 24 Jun 2025 09:59:19 +0200 Subject: [PATCH 11/76] Dummy app: Reword smart immersive input section --- tests/dummy/app/templates/smart.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index 7eb83de1f..f56d047d7 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -10,7 +10,7 @@ class="fx-col fx-1 background-color-white border border-color-default border-radius-md padding-px-12 fx-gap-px-12" >
- Smart input immersive + Smart immersive input
From 5b36c2c1e92e386af048832e6003f2eb9d56431d Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Mon, 23 Jun 2025 11:21:56 +0200 Subject: [PATCH 12/76] feat: added smart input component --- addon/components/o-s-s/smart/input.hbs | 52 ++++++++++++ addon/components/o-s-s/smart/input.stories.js | 68 +++++++++++++++ addon/components/o-s-s/smart/input.ts | 40 +++++++++ app/components/o-s-s/smart/input.js | 1 + app/styles/atoms/smart-input.less | 81 ++++++++++++++++++ app/styles/core/_smart.less | 33 ++++++++ tests/dummy/app/controllers/smart.ts | 6 ++ tests/dummy/app/templates/smart.hbs | 29 +++++++ .../components/o-s-s/smart/input-test.ts | 82 +++++++++++++++++++ 9 files changed, 392 insertions(+) create mode 100644 addon/components/o-s-s/smart/input.hbs create mode 100644 addon/components/o-s-s/smart/input.stories.js create mode 100644 addon/components/o-s-s/smart/input.ts create mode 100644 app/components/o-s-s/smart/input.js create mode 100644 app/styles/atoms/smart-input.less create mode 100644 tests/integration/components/o-s-s/smart/input-test.ts diff --git a/addon/components/o-s-s/smart/input.hbs b/addon/components/o-s-s/smart/input.hbs new file mode 100644 index 000000000..6c6ac1fae --- /dev/null +++ b/addon/components/o-s-s/smart/input.hbs @@ -0,0 +1,52 @@ +
+
+ {{#if (has-block "prefix")}} +
{{yield to="prefix"}}
+ {{/if}} + + {{#if (has-block "input")}} +
+ {{yield to="input"}} +
+ {{else}} + {{#if @loading}} +
+ {{@placeholder}} +
+ {{else}} + + {{/if}} + {{/if}} + + {{#if (has-block "suffix")}} +
{{yield to="suffix"}}
+ {{/if}} +
+ + {{#if @errorMessage}} + {{@errorMessage}} + {{else if this.feedbackMessage}} + + {{#unless (eq this.feedbackMessage.type "error")}} + + {{/unless}} + {{this.feedbackMessage.value}} + + {{/if}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/input.stories.js b/addon/components/o-s-s/smart/input.stories.js new file mode 100644 index 000000000..826fe2e99 --- /dev/null +++ b/addon/components/o-s-s/smart/input.stories.js @@ -0,0 +1,68 @@ +import { hbs } from 'ember-cli-htmlbars'; + +export default { + title: 'Components/OSS::Smart::Input', + component: 'button', + argTypes: { + value: { + type: { required: true }, + control: 'text', + description: 'The value of the input', + defaultValue: 'Input value', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'Input value' } + } + }, + placeholder: { + type: { required: true }, + control: 'text', + description: 'Placeholder text for the input', + defaultValue: 'Placeholder', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'Placeholder' } + } + }, + loading: { + type: { required: true }, + control: 'boolean', + description: 'Flag to display loading state', + defaultValue: false, + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + } + }, + onChange: { + type: { required: true }, + description: 'The action triggered when the input value is changed', + table: { + category: 'Actions', + type: { summary: 'onChange(value: boolean): void' } + } + } + } +}; + +const Template = (args) => ({ + template: hbs` + + `, + context: args +}); + +const defaultArgs = { + value: 'Input value', + placeholder: 'Placeholder', + loading: false, + onChange: (value) => console.log('Input changed:', value) +}; + +export const Default = Template.bind({}); +Default.args = defaultArgs; diff --git a/addon/components/o-s-s/smart/input.ts b/addon/components/o-s-s/smart/input.ts new file mode 100644 index 000000000..f75e5dfda --- /dev/null +++ b/addon/components/o-s-s/smart/input.ts @@ -0,0 +1,40 @@ +import OSSInputContainer from '../input-container'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { runSmartGradientAnimation } from '@upfluence/oss-components/utils/run-smart-gradient-animation'; +import { isEmpty } from '@ember/utils'; + +interface SmartInputArgs { + value?: string; + placeholder?: string; + loading: boolean; + onChange?: (value: string) => void; +} + +export default class OSSSmartInput extends OSSInputContainer { + @tracked declare element: HTMLElement; + + constructor(owner: unknown, args: SmartInputArgs) { + super(owner, args); + } + + get hasGeneratedValue(): boolean { + return !this.args.loading && !isEmpty(this.args.value); + } + + get hasValue(): boolean { + return !isEmpty(this.args.value); + } + + @action + handleUpdate(): void { + if (!this.args.loading && !isEmpty(this.args.value)) { + runSmartGradientAnimation(this.element); + } + } + + @action + registerElement(element: HTMLElement): void { + this.element = element; + } +} diff --git a/app/components/o-s-s/smart/input.js b/app/components/o-s-s/smart/input.js new file mode 100644 index 000000000..8c0aef15a --- /dev/null +++ b/app/components/o-s-s/smart/input.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/input'; diff --git a/app/styles/atoms/smart-input.less b/app/styles/atoms/smart-input.less new file mode 100644 index 000000000..3124d60d4 --- /dev/null +++ b/app/styles/atoms/smart-input.less @@ -0,0 +1,81 @@ +.oss-smart-input-container { + width: 100%; + + .oss-input-container { + width: 100%; + position: relative; + } + + .smart-input__animated-text { + .smart-input__animated-text--normal.text { + color: transparent; + width: 100%; + } + + .upf-input { + position: relative; + width: 100%; + border: 1px solid var(--color-gray-100); + } + + .smart-input__animated-text--fill.fill-text { + justify-content: start; + word-break: keep-all; + width: 0; + background: linear-gradient( + 276.39deg, + #d8dae4 -27.68%, + #d0d9ff -5.84%, + #fac6ff 8.68%, + rgba(255, 255, 255, 0.3) 17.85%, + rgba(208, 217, 255, 0.8) 28.01%, + rgba(250, 198, 255, 0.7) 37.18% + ); + + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + transition: 0.5s width ease-in-out; + } + + &.smart-input-wrapper { + position: relative; + border: none; + min-width: 50px; + } + + .smart-input__animated-text--normal { + .font-weight-semibold; + color: var(--color-gray-400); + padding: 5px var(--spacing-px-6); + } + + .smart-input__animated-text--fill { + width: 100%; + padding-top: 8px; + animation: rainbow_animation 4s linear infinite; + justify-content: start; + overflow: hidden; + word-break: keep-all; + width: 0; + max-width: fit-content; + height: 100%; + background: linear-gradient( + 135deg, + #d8dae4 -27.68%, + #d0d9ff -5.84%, + #fac6ff 8.68%, + rgba(255, 255, 255, 0.3) 17.85%, + rgba(208, 217, 255, 0.8) 28.01%, + rgba(250, 198, 255, 0.7) 37.18% + ); + + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + transition: 0.5s width ease-in-out; + + animation: animate-letters 2s linear infinite; + } + } +} diff --git a/app/styles/core/_smart.less b/app/styles/core/_smart.less index 42f1e5f06..dc3e586d7 100644 --- a/app/styles/core/_smart.less +++ b/app/styles/core/_smart.less @@ -1,3 +1,36 @@ :root { --border-radius-xl: 999px; } + +@import '../atoms/smart-input.less'; + +.rainbow_text_animated { + // .font-weight-semibold; + + background: linear-gradient( + 129deg, + var(--color-gray-400) 19%, + rgba(216, 218, 228, 1) 24%, + rgba(208, 217, 255, 1) 31%, + rgba(250, 198, 255, 1) 42%, + rgba(255, 255, 255, 0.3) 61%, + rgba(208, 217, 255, 0.8) 64%, + rgba(250, 198, 255, 0.7) 69%, + var(--color-gray-400) 75% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: rainbow_animation 4s ease-in-out infinite; + background-size: 600% 100%; +} + +@keyframes rainbow_animation { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0 0%; + } +} diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index 2df773b48..ed481982c 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -5,6 +5,7 @@ import { tracked } from '@glimmer/tracking'; export default class Smart extends Controller { @tracked selectedToggle: string = 'first'; @tracked selectedToggleTwo: string = 'second'; + @tracked toggleInputLoadingValue: boolean = false; @tracked toggles: { value: string; label: string }[] = [ { value: 'first', @@ -33,4 +34,9 @@ export default class Smart extends Controller { this.value = 'Data loaded from a very smart backend'; } } + + @action + toggleInputLoading(): void { + this.toggleInputLoadingValue = !this.toggleInputLoadingValue; + } } diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index f56d047d7..914cf02df 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -202,4 +202,33 @@
+
+ Smart Inputs + +
+
+ + + + + +
\ No newline at end of file diff --git a/tests/integration/components/o-s-s/smart/input-test.ts b/tests/integration/components/o-s-s/smart/input-test.ts new file mode 100644 index 000000000..5a58fc1e6 --- /dev/null +++ b/tests/integration/components/o-s-s/smart/input-test.ts @@ -0,0 +1,82 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, find, findAll, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | o-s-s/smart/input', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.value = ''; + this.loading = false; + this.label = ''; + }); + + test('it renders empty by default', async function (assert) { + await render(hbs``); + assert.dom().exists(); + }); + + test('it renders a label if passed', async function (assert) { + await render(hbs``); + assert.dom('label, span').hasText('Username'); + }); + + test('it renders placeholder if passed', async function (assert) { + await render(hbs``); + assert.dom('input').hasAttribute('placeholder', 'Enter email'); + }); + + test('it binds @value to the input', async function (assert) { + this.value = 'demo@example.com'; + await render(hbs``); + assert.dom('input').hasValue('demo@example.com'); + }); + + test('it shows animated text when loading is true', async function (assert) { + this.value = 'Value'; + this.set('loading', true); + + await render(hbs` + + `); + + assert.dom('.smart-input__animated-text').exists(''); + assert.dom('.smart-input__animated-text--normal').hasText('Value'); + }); + + test('it transitions back to normal input when loading becomes false', async function (assert) { + this.setProperties({ + value: 'Done', + loading: true + }); + + await render(hbs` + + `); + + assert.dom('.smart-input__animated-text').exists(); + + // Simulate loading -> false + this.set('loading', false); + await settled(); + + assert.dom('input').hasValue('Done'); + }); + + test('it shows error message when @errorMessage is passed', async function (assert) { + await render(hbs` + + `); + + assert.dom('.text-color-error').hasText('Something went wrong'); + }); +}); From b4df87dea65514e24c5c22dc35770384c1c9a43f Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Mon, 23 Jun 2025 11:35:49 +0200 Subject: [PATCH 13/76] fix: fixed broken tests --- .../components/o-s-s/smart/input-test.ts | 38 +++---------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/tests/integration/components/o-s-s/smart/input-test.ts b/tests/integration/components/o-s-s/smart/input-test.ts index 5a58fc1e6..813108b2a 100644 --- a/tests/integration/components/o-s-s/smart/input-test.ts +++ b/tests/integration/components/o-s-s/smart/input-test.ts @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, find, findAll, settled } from '@ember/test-helpers'; +import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | o-s-s/smart/input', function (hooks) { @@ -17,11 +17,6 @@ module('Integration | Component | o-s-s/smart/input', function (hooks) { assert.dom().exists(); }); - test('it renders a label if passed', async function (assert) { - await render(hbs``); - assert.dom('label, span').hasText('Username'); - }); - test('it renders placeholder if passed', async function (assert) { await render(hbs``); assert.dom('input').hasAttribute('placeholder', 'Enter email'); @@ -34,40 +29,17 @@ module('Integration | Component | o-s-s/smart/input', function (hooks) { }); test('it shows animated text when loading is true', async function (assert) { - this.value = 'Value'; - this.set('loading', true); + this.placeholder = 'placeholder'; + this.loading = true; await render(hbs` `); - assert.dom('.smart-input__animated-text').exists(''); - assert.dom('.smart-input__animated-text--normal').hasText('Value'); - }); - - test('it transitions back to normal input when loading becomes false', async function (assert) { - this.setProperties({ - value: 'Done', - loading: true - }); - - await render(hbs` - - `); - - assert.dom('.smart-input__animated-text').exists(); - - // Simulate loading -> false - this.set('loading', false); - await settled(); - - assert.dom('input').hasValue('Done'); + assert.dom('.rainbow_text_animated').hasText(this.placeholder); }); test('it shows error message when @errorMessage is passed', async function (assert) { From 3de0d379de4af6cfd9c2095ba6121d12578685c8 Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Mon, 23 Jun 2025 16:19:45 +0200 Subject: [PATCH 14/76] fix: cleanup --- addon/components/o-s-s/smart/input.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/addon/components/o-s-s/smart/input.ts b/addon/components/o-s-s/smart/input.ts index f75e5dfda..2fda173a2 100644 --- a/addon/components/o-s-s/smart/input.ts +++ b/addon/components/o-s-s/smart/input.ts @@ -18,14 +18,6 @@ export default class OSSSmartInput extends OSSInputContainer { super(owner, args); } - get hasGeneratedValue(): boolean { - return !this.args.loading && !isEmpty(this.args.value); - } - - get hasValue(): boolean { - return !isEmpty(this.args.value); - } - @action handleUpdate(): void { if (!this.args.loading && !isEmpty(this.args.value)) { From 348bad8b7119247883455f6b166b16feec7a6e74 Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Wed, 25 Jun 2025 09:02:41 +0200 Subject: [PATCH 15/76] fix: fixes post review --- app/styles/atoms/smart-input.less | 48 ------------------------------- 1 file changed, 48 deletions(-) diff --git a/app/styles/atoms/smart-input.less b/app/styles/atoms/smart-input.less index 3124d60d4..8e71ca2c4 100644 --- a/app/styles/atoms/smart-input.less +++ b/app/styles/atoms/smart-input.less @@ -18,26 +18,6 @@ border: 1px solid var(--color-gray-100); } - .smart-input__animated-text--fill.fill-text { - justify-content: start; - word-break: keep-all; - width: 0; - background: linear-gradient( - 276.39deg, - #d8dae4 -27.68%, - #d0d9ff -5.84%, - #fac6ff 8.68%, - rgba(255, 255, 255, 0.3) 17.85%, - rgba(208, 217, 255, 0.8) 28.01%, - rgba(250, 198, 255, 0.7) 37.18% - ); - - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - transition: 0.5s width ease-in-out; - } - &.smart-input-wrapper { position: relative; border: none; @@ -49,33 +29,5 @@ color: var(--color-gray-400); padding: 5px var(--spacing-px-6); } - - .smart-input__animated-text--fill { - width: 100%; - padding-top: 8px; - animation: rainbow_animation 4s linear infinite; - justify-content: start; - overflow: hidden; - word-break: keep-all; - width: 0; - max-width: fit-content; - height: 100%; - background: linear-gradient( - 135deg, - #d8dae4 -27.68%, - #d0d9ff -5.84%, - #fac6ff 8.68%, - rgba(255, 255, 255, 0.3) 17.85%, - rgba(208, 217, 255, 0.8) 28.01%, - rgba(250, 198, 255, 0.7) 37.18% - ); - - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - transition: 0.5s width ease-in-out; - - animation: animate-letters 2s linear infinite; - } } } From 670fab093ec2044e8e4bf22e60bba220329403df Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Wed, 25 Jun 2025 10:42:50 +0200 Subject: [PATCH 16/76] fix: added fixes post review --- addon/components/o-s-s/smart/input.stories.js | 125 ++++++++++---- addon/components/o-s-s/smart/input.ts | 4 - app/styles/core/_smart.less | 2 - .../components/o-s-s/smart/input-test.ts | 161 ++++++++++++++---- 4 files changed, 220 insertions(+), 72 deletions(-) diff --git a/addon/components/o-s-s/smart/input.stories.js b/addon/components/o-s-s/smart/input.stories.js index 826fe2e99..8ab1389f9 100644 --- a/addon/components/o-s-s/smart/input.stories.js +++ b/addon/components/o-s-s/smart/input.stories.js @@ -1,27 +1,90 @@ import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; export default { title: 'Components/OSS::Smart::Input', component: 'button', argTypes: { - value: { - type: { required: true }, - control: 'text', - description: 'The value of the input', - defaultValue: 'Input value', - table: { - type: { summary: 'string' }, - defaultValue: { summary: 'Input value' } - } - }, - placeholder: { - type: { required: true }, - control: 'text', - description: 'Placeholder text for the input', - defaultValue: 'Placeholder', - table: { - type: { summary: 'string' }, - defaultValue: { summary: 'Placeholder' } + argTypes: { + value: { + description: 'Value of the input', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + type: { + description: 'The input type', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'text' } + }, + control: { type: 'text' } + }, + disabled: { + description: 'Disable the default input (when not passing an input named block)', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + placeholder: { + description: 'Placeholder of the input', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + feedbackMessage: { + description: 'A success, warning or error message that will be displayed below the input-group.', + table: { + type: { + summary: '{ type: string, value: string }' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' } + }, + errorMessage: { + description: 'An error message that will be displayed below the input-group.', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + hasError: { + description: + 'Allows setting the error style on the input without showing an error message. Useful for form validation.', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'boolean' } + }, + onChange: { + description: 'Method called every time the input is updated', + table: { + category: 'Actions', + type: { + summary: 'onChange(value: string): void' + } + } } }, loading: { @@ -33,14 +96,6 @@ export default { type: { summary: 'boolean' }, defaultValue: { summary: false } } - }, - onChange: { - type: { required: true }, - description: 'The action triggered when the input value is changed', - table: { - category: 'Actions', - type: { summary: 'onChange(value: boolean): void' } - } } } }; @@ -48,20 +103,26 @@ export default { const Template = (args) => ({ template: hbs` `, context: args }); const defaultArgs = { - value: 'Input value', - placeholder: 'Placeholder', - loading: false, - onChange: (value) => console.log('Input changed:', value) + value: 'John', + disabled: false, + type: undefined, + placeholder: 'this is the placeholder', + errorMessage: undefined, + onChange: action('onChange'), + loading: false }; export const Default = Template.bind({}); diff --git a/addon/components/o-s-s/smart/input.ts b/addon/components/o-s-s/smart/input.ts index 2fda173a2..866214d4d 100644 --- a/addon/components/o-s-s/smart/input.ts +++ b/addon/components/o-s-s/smart/input.ts @@ -14,10 +14,6 @@ interface SmartInputArgs { export default class OSSSmartInput extends OSSInputContainer { @tracked declare element: HTMLElement; - constructor(owner: unknown, args: SmartInputArgs) { - super(owner, args); - } - @action handleUpdate(): void { if (!this.args.loading && !isEmpty(this.args.value)) { diff --git a/app/styles/core/_smart.less b/app/styles/core/_smart.less index dc3e586d7..ebdb383ef 100644 --- a/app/styles/core/_smart.less +++ b/app/styles/core/_smart.less @@ -5,8 +5,6 @@ @import '../atoms/smart-input.less'; .rainbow_text_animated { - // .font-weight-semibold; - background: linear-gradient( 129deg, var(--color-gray-400) 19%, diff --git a/tests/integration/components/o-s-s/smart/input-test.ts b/tests/integration/components/o-s-s/smart/input-test.ts index 813108b2a..d8b722649 100644 --- a/tests/integration/components/o-s-s/smart/input-test.ts +++ b/tests/integration/components/o-s-s/smart/input-test.ts @@ -1,54 +1,147 @@ +import { hbs } from 'ember-cli-htmlbars'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; +import { render, find, typeIn, triggerEvent } from '@ember/test-helpers'; +import sinon from 'sinon'; module('Integration | Component | o-s-s/smart/input', function (hooks) { setupRenderingTest(hooks); - hooks.beforeEach(function () { - this.value = ''; - this.loading = false; - this.label = ''; - }); - - test('it renders empty by default', async function (assert) { + test('it renders', async function (assert) { await render(hbs``); - assert.dom().exists(); + assert.dom('.upf-input').exists(); }); - test('it renders placeholder if passed', async function (assert) { - await render(hbs``); - assert.dom('input').hasAttribute('placeholder', 'Enter email'); + module('Input Blocks', () => { + async function renderComponentWithPrefixSuffix() { + await render(hbs` + + <:prefix> + + + <:input> + + + <:suffix> + + + `); + } + + test('Prefix block renders properly', async function (assert) { + await renderComponentWithPrefixSuffix(); + assert.dom('.fa-user').exists(); + }); + + test('Suffix block renders properly', async function (assert) { + await renderComponentWithPrefixSuffix(); + assert.dom('.fa-times').exists(); + }); + + test('Custom input block overrides default', async function (assert) { + await renderComponentWithPrefixSuffix(); + assert.dom('#custom-input').exists(); + }); }); - test('it binds @value to the input', async function (assert) { - this.value = 'demo@example.com'; - await render(hbs``); - assert.dom('input').hasValue('demo@example.com'); + module('Component Parameters', (hooks) => { + let onValueChange: sinon.SinonSpy; + hooks.beforeEach(function () { + onValueChange = sinon.fake(); + this.value = 'smartInput'; + this.placeholder = 'Type here'; + this.onValueChange = onValueChange; + }); + + async function renderComponentWithParams() { + await render(hbs` + + `); + } + + test('Binds @value correctly', async function (assert) { + await renderComponentWithParams(); + assert.dom('.upf-input').hasValue('smartInput'); + }); + + test('Sets @placeholder correctly', async function (assert) { + await renderComponentWithParams(); + let input = find('.upf-input'); + assert.strictEqual(input?.getAttribute('placeholder'), 'Type here'); + }); + + test('Calls @onChange on input', async function (assert) { + await renderComponentWithParams(); + let input = find('.upf-input'); + assert.ok(input, 'Input element should exist'); + await typeIn(input as Element, 'X'); + assert.ok(onValueChange.called); + }); + + test('Calls @onChange on paste event', async function (assert) { + this.set('onChange', sinon.stub()); + await render(hbs` + + `); + assert.ok(this.onChange.notCalled); + + await triggerEvent('.oss-input-container input', 'paste', { + clipboardData: { getData: (format: any) => `clip/${format}` } + }); + + assert.ok(this.onChange.calledWith('clip/Text')); + }); }); - test('it shows animated text when loading is true', async function (assert) { - this.placeholder = 'placeholder'; - this.loading = true; + module('Smart Input Specific Features', () => { + test('Displays animated placeholder when loading is true', async function (assert) { + this.setProperties({ loading: true, placeholder: 'Smart...' }); + + await render(hbs` + + `); + + assert.dom('.rainbow_text_animated').hasText('Smart...'); + }); + }); - await render(hbs` - - `); + module('Feedback and Error States', () => { + test('Displays success feedback', async function (assert) { + await render(hbs` + + `); + assert.dom('.oss-input-container--success').exists(); + assert.dom('.font-color-success-500').hasText('All good!'); + }); - assert.dom('.rainbow_text_animated').hasText(this.placeholder); + test('Displays error message', async function (assert) { + await render(hbs``); + assert.dom('.oss-input-container--errored').exists(); + assert.dom('.text-color-error').hasText('Oops'); + }); }); - test('it shows error message when @errorMessage is passed', async function (assert) { - await render(hbs` - - `); + module('Extra attributes', () => { + test('Custom class is applied', async function (assert) { + await render(hbs``); + assert.dom('.extra-class').exists(); + }); - assert.dom('.text-color-error').hasText('Something went wrong'); + test('data-control-name is passed through', async function (assert) { + await render(hbs``); + assert.dom('.oss-input-container').hasAttribute('data-control-name', 'smart-name'); + }); }); }); From 16bfb011544ff996fa3ade568835b4c9509f37a2 Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Mon, 23 Jun 2025 16:18:20 +0200 Subject: [PATCH 17/76] wip: added feedback component --- addon/components/o-s-s/smart/feedback.hbs | 25 +++++++++++++++ addon/components/o-s-s/smart/feedback.ts | 32 +++++++++++++++++++ app/components/o-s-s/smart/feedback.js | 1 + app/styles/deprecated/_fonts.less | 2 +- tests/dummy/app/controllers/smart.ts | 16 ++++++++++ tests/dummy/app/templates/smart.hbs | 13 ++++++++ .../components/o-s-s/smart/feedback-test.ts | 26 +++++++++++++++ 7 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 addon/components/o-s-s/smart/feedback.hbs create mode 100644 addon/components/o-s-s/smart/feedback.ts create mode 100644 app/components/o-s-s/smart/feedback.js create mode 100644 tests/integration/components/o-s-s/smart/feedback-test.ts diff --git a/addon/components/o-s-s/smart/feedback.hbs b/addon/components/o-s-s/smart/feedback.hbs new file mode 100644 index 000000000..3159f681a --- /dev/null +++ b/addon/components/o-s-s/smart/feedback.hbs @@ -0,0 +1,25 @@ + diff --git a/addon/components/o-s-s/smart/feedback.ts b/addon/components/o-s-s/smart/feedback.ts new file mode 100644 index 000000000..a040673f2 --- /dev/null +++ b/addon/components/o-s-s/smart/feedback.ts @@ -0,0 +1,32 @@ +import Component from '@glimmer/component'; + +interface OSSSmartFeedbackArgs { + loading: boolean; + contentString?: string; + contentArray?: string[]; +} + +export default class OSSSmartFeedback extends Component { + constructor(owner: unknown, args: OSSSmartFeedbackArgs) { + super(owner, args); + } + + get currentStateClass(): string | null { + console.log(this.args.loading); + if (this.args.loading === true) return 'oss-smart__feedback__loading'; + if (this.args.loading === false && (this.args.contentString || this.args.contentArray)) + return 'oss-smart__generated'; + return null; + } + + get contentArrayWithDelays() { + if (!this.args.contentArray) return []; + + return this.args.contentArray.map((item, index) => { + return { + text: item, + delay: `${index * 0.5}s` + }; + }); + } +} diff --git a/app/components/o-s-s/smart/feedback.js b/app/components/o-s-s/smart/feedback.js new file mode 100644 index 000000000..97e7066fa --- /dev/null +++ b/app/components/o-s-s/smart/feedback.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/feedback'; diff --git a/app/styles/deprecated/_fonts.less b/app/styles/deprecated/_fonts.less index 544cb4580..2442e984e 100644 --- a/app/styles/deprecated/_fonts.less +++ b/app/styles/deprecated/_fonts.less @@ -1 +1 @@ -@import url('https://fonts.googleapis.com/css?family=Open+Sans:400,400i,600,600i,700,700i&display=swap&subset=cyrillic,cyrillic-ext,greek,greek-ext,latin-ext,vietnamese'); +@import url('https://fonts.googleapis.com/css?family=Reddit+Sans:wght@400&family=Open+Sans:400,400i,600,600i,700,700i&display=swap&subset=cyrillic,cyrillic-ext,greek,greek-ext,latin-ext,vietnamese'); diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index ed481982c..2871a779d 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -6,6 +6,18 @@ export default class Smart extends Controller { @tracked selectedToggle: string = 'first'; @tracked selectedToggleTwo: string = 'second'; @tracked toggleInputLoadingValue: boolean = false; + @tracked smartFeedback: boolean = false; + @tracked contentArray: string[] = [ + 'This is the first content', + 'This is the second content', + 'This is the third content', + 'This is the first content', + 'This is the second content', + 'This is the third content', + 'This is the first content', + 'This is the second content', + 'This is the third content' + ]; @tracked toggles: { value: string; label: string }[] = [ { value: 'first', @@ -39,4 +51,8 @@ export default class Smart extends Controller { toggleInputLoading(): void { this.toggleInputLoadingValue = !this.toggleInputLoadingValue; } + @action + toggleSmartFeedbackLoading(): void { + this.smartFeedback = !this.smartFeedback; + } } diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index 914cf02df..3d031c1e6 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -199,6 +199,19 @@
+ +
+ +
+
+ Smart feedback : loading {{this.smartFeedback}} + +
+ + <:blob> + + +
diff --git a/tests/integration/components/o-s-s/smart/feedback-test.ts b/tests/integration/components/o-s-s/smart/feedback-test.ts new file mode 100644 index 000000000..72fcfe18c --- /dev/null +++ b/tests/integration/components/o-s-s/smart/feedback-test.ts @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | o-s-s/smart/feedback', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function (val) { ... }); + + await render(hbs``); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom().hasText('template block text'); + }); +}); From 016a67e2d6b7916a08355452d8330401f9e50eee Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Tue, 24 Jun 2025 11:02:19 +0200 Subject: [PATCH 18/76] fix: added story & fixed styling --- addon/components/o-s-s/smart/feedback.hbs | 14 ++-- .../o-s-s/smart/feedback.stories.js | 81 +++++++++++++++++++ addon/components/o-s-s/smart/feedback.ts | 16 ---- tests/dummy/app/controllers/smart.ts | 55 +++++++++---- tests/dummy/app/templates/smart.hbs | 15 ++-- .../components/o-s-s/smart/feedback-test.ts | 53 ++++++++++-- 6 files changed, 182 insertions(+), 52 deletions(-) create mode 100644 addon/components/o-s-s/smart/feedback.stories.js diff --git a/addon/components/o-s-s/smart/feedback.hbs b/addon/components/o-s-s/smart/feedback.hbs index 3159f681a..5e7d99a41 100644 --- a/addon/components/o-s-s/smart/feedback.hbs +++ b/addon/components/o-s-s/smart/feedback.hbs @@ -1,25 +1,25 @@ + \ No newline at end of file diff --git a/addon/components/o-s-s/smart/feedback.stories.js b/addon/components/o-s-s/smart/feedback.stories.js new file mode 100644 index 000000000..407ba5068 --- /dev/null +++ b/addon/components/o-s-s/smart/feedback.stories.js @@ -0,0 +1,81 @@ +import { hbs } from 'ember-cli-htmlbars'; + +export default { + title: 'Components/OSS::Smart::Feedback', + component: 'oss-smart-feedback', + argTypes: { + loading: { + description: 'Whether the feedback component is in a loading state', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + contentString: { + description: 'Text content to display when not loading', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '' } + }, + control: { type: 'text' } + }, + contentArray: { + description: 'Array of text lines to display instead of a single string', + table: { + type: { summary: 'string[]' }, + defaultValue: { summary: '[]' } + }, + control: { type: 'object' } + } + }, + parameters: { + docs: { + description: { + component: + 'Component used to display feedback with optional loading skeletons and dynamic content (string or array).' + }, + iframeHeight: 250 + } + } +}; + +const defaultArgs = { + loading: false, + contentString: 'This is feedback content.', + contentArray: [] +}; + +const Template = (args) => ({ + template: hbs` +
+ + <:icon> +
+ +
+
+ `, + context: args +}); + +export const Default = Template.bind({}); +Default.args = defaultArgs; + +export const WithArrayContent = Template.bind({}); +WithArrayContent.args = { + loading: false, + contentArray: ['Line 1', 'Line 2', 'Line 3'], + contentString: '' +}; + +export const Loading = Template.bind({}); +Loading.args = { + loading: true, + contentString: '', + contentArray: [] +}; diff --git a/addon/components/o-s-s/smart/feedback.ts b/addon/components/o-s-s/smart/feedback.ts index a040673f2..5dc857674 100644 --- a/addon/components/o-s-s/smart/feedback.ts +++ b/addon/components/o-s-s/smart/feedback.ts @@ -7,26 +7,10 @@ interface OSSSmartFeedbackArgs { } export default class OSSSmartFeedback extends Component { - constructor(owner: unknown, args: OSSSmartFeedbackArgs) { - super(owner, args); - } - get currentStateClass(): string | null { - console.log(this.args.loading); if (this.args.loading === true) return 'oss-smart__feedback__loading'; if (this.args.loading === false && (this.args.contentString || this.args.contentArray)) return 'oss-smart__generated'; return null; } - - get contentArrayWithDelays() { - if (!this.args.contentArray) return []; - - return this.args.contentArray.map((item, index) => { - return { - text: item, - delay: `${index * 0.5}s` - }; - }); - } } diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index 2871a779d..9ee6d6924 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -7,30 +7,25 @@ export default class Smart extends Controller { @tracked selectedToggleTwo: string = 'second'; @tracked toggleInputLoadingValue: boolean = false; @tracked smartFeedback: boolean = false; + @tracked smartFeedbackLoading: boolean = false; @tracked contentArray: string[] = [ - 'This is the first content', - 'This is the second content', - 'This is the third content', - 'This is the first content', - 'This is the second content', - 'This is the third content', 'This is the first content', 'This is the second content', 'This is the third content' ]; @tracked toggles: { value: string; label: string }[] = [ - { - value: 'first', - label: 'First' - }, - { - value: 'second', - label: 'Second' - } + { value: 'first', label: 'First' }, + { value: 'second', label: 'Second' } ]; @tracked declare value: string; @tracked loading: boolean = false; + intervalId?: number; + + constructor() { + super(...arguments); + this.addContentToFeedbackComponent(); + } @action triggerSelection(value: string): void { @@ -53,6 +48,36 @@ export default class Smart extends Controller { } @action toggleSmartFeedbackLoading(): void { - this.smartFeedback = !this.smartFeedback; + this.smartFeedbackLoading = !this.smartFeedbackLoading; + } + + addContentToFeedbackComponent(): void { + const wordsToAdd = ['Dynamic word 1', 'Dynamic word 2', 'Dynamic word 3', 'Dynamic word 4', 'Dynamic word 5']; + + let index = 0; + + if (this.intervalId) { + clearInterval(this.intervalId); + } + + this.intervalId = window.setInterval(() => { + if (index >= wordsToAdd.length) { + clearInterval(this.intervalId); + return; + } + + const wordToAdd = wordsToAdd[index]; + if (typeof wordToAdd === 'string') { + this.contentArray = [...this.contentArray, wordToAdd]; + } + index++; + }, 1000); + } + + stopAddingContent(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } } } diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index 3d031c1e6..5c66ce908 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -199,18 +199,21 @@
- + +
- Smart feedback : loading {{this.smartFeedback}} - + Smart feedback : loading {{this.smartFeedbackLoading}} +
- - <:blob> + + <:icon> - +
diff --git a/tests/integration/components/o-s-s/smart/feedback-test.ts b/tests/integration/components/o-s-s/smart/feedback-test.ts index 72fcfe18c..186c5b03a 100644 --- a/tests/integration/components/o-s-s/smart/feedback-test.ts +++ b/tests/integration/components/o-s-s/smart/feedback-test.ts @@ -6,21 +6,58 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | o-s-s/smart/feedback', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function (val) { ... }); - + test('it renders the component container', async function (assert) { await render(hbs``); + assert.dom('.oss-smart__feedback').exists(); + }); + + test('it shows loading skeletons when @loading is true', async function (assert) { + this.loading = true; - assert.dom().hasText(''); + await render(hbs``); + + assert.dom('.upf-smart-skeleton-effect').exists({ count: 2 }); + }); + + test('it renders content string when @loading is false and @contentString is provided', async function (assert) { + this.loading = false; + this.contentString = 'Test feedback message'; + + await render(hbs` + + `); + + assert.dom('.oss-smart__generated').exists(); + assert.dom('.oss-smart__feedback__content__text').hasText('Test feedback message'); + }); + + test('it renders content array when @loading is false and @contentArray is provided', async function (assert) { + this.loading = false; + this.contentArray = ['First line', ' Second line']; + + await render(hbs` + + `); + + assert.dom('.oss-smart__generated').exists(); + assert.dom('.oss-smart__feedback__content__text').hasText('First line Second line'); + }); - // Template block usage: + test('it yields the icon named-block content', async function (assert) { await render(hbs` - template block text + <:icon> +
Blob Content
+
`); - assert.dom().hasText('template block text'); + assert.dom('.test-blob').hasText('Blob Content'); }); }); From 091267c4307e806454ad75dd8994b70a5178d3fb Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Tue, 24 Jun 2025 11:16:43 +0200 Subject: [PATCH 19/76] fix: remove unused method --- tests/dummy/app/controllers/smart.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index 9ee6d6924..3eb83432e 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -73,11 +73,4 @@ export default class Smart extends Controller { index++; }, 1000); } - - stopAddingContent(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = undefined; - } - } } From ff15a4260624be8f2d6c49178b2378269e3f61da Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Wed, 25 Jun 2025 09:13:09 +0200 Subject: [PATCH 20/76] fix: added fixes post review --- addon/components/o-s-s/smart/feedback.hbs | 2 +- addon/components/o-s-s/smart/feedback.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/addon/components/o-s-s/smart/feedback.hbs b/addon/components/o-s-s/smart/feedback.hbs index 5e7d99a41..547f1d1da 100644 --- a/addon/components/o-s-s/smart/feedback.hbs +++ b/addon/components/o-s-s/smart/feedback.hbs @@ -1,4 +1,4 @@ - {{else}} {{#if @contentString}} - + {{else if @contentArray}}
-
- Smart Logo - -
-
- - - -
@@ -326,21 +308,35 @@
- + - + - + - +
- \ No newline at end of file + diff --git a/tests/integration/components/o-s-s/smart/immersive/logo-test.ts b/tests/integration/components/o-s-s/smart/immersive/logo-test.ts index 35afb1624..8c862bfc2 100644 --- a/tests/integration/components/o-s-s/smart/immersive/logo-test.ts +++ b/tests/integration/components/o-s-s/smart/immersive/logo-test.ts @@ -7,6 +7,9 @@ import { LOGO_COLORS, LOGO_ICONS } from 'dummy/utils/logo-config'; module('Integration | Component | o-s-s/smart/immersive/logo', function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + this.onEdit = sinon.stub(); + }); test('it renders with icon mode', async function (assert) { this.icon = 'fa:star'; @@ -41,31 +44,30 @@ module('Integration | Component | o-s-s/smart/immersive/logo', function (hooks) test('it renders edit overlay when editable', async function (assert) { this.icon = 'fa:pen'; this.editable = true; - this.onClick = sinon.stub(); + this.onEdit = sinon.stub(); await render(hbs``); assert.dom('.edit-overlay').exists(); }); - test('onClick is called when the component is clicked', async function (assert) { + test('onEdit is called when the component is clicked', async function (assert) { this.icon = 'fa:pen'; this.editable = true; - this.onClick = sinon.stub(); await render(hbs``); assert.dom('.edit-overlay').exists(); await this.element.querySelector('.edit-overlay').click(); - assert.ok(this.onClick.calledOnce); + assert.ok(this.onEdit.calledOnce); }); LOGO_ICONS.forEach((iconName) => { @@ -144,7 +146,7 @@ module('Integration | Component | o-s-s/smart/immersive/logo', function (hooks) await render(hbs``); assert.dom('.campaign-image').exists('Fallback image container is rendered'); From aaff089d3bd619cdd7177458e85e02621ac0cd77 Mon Sep 17 00:00:00 2001 From: Nathalie Date: Wed, 2 Jul 2025 12:05:04 +0200 Subject: [PATCH 39/76] Added @icon arg on OSS::Pill & Smart::Pill --- tests/dummy/app/templates/smart.hbs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index 2caadda06..cb047afaf 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -290,9 +290,14 @@
- + - +
@@ -339,4 +344,4 @@
- + \ No newline at end of file From ce30edf4330ff46c2cf39811233d4c3ddca71bdc Mon Sep 17 00:00:00 2001 From: Nathalie Date: Wed, 2 Jul 2025 14:44:28 +0200 Subject: [PATCH 40/76] Added icon helpers --- tests/dummy/app/templates/smart.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index cb047afaf..b983c09f0 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -292,7 +292,7 @@
From 4406642a62d2987723b711127576a0acfbfbe43a Mon Sep 17 00:00:00 2001 From: Nathalie Date: Wed, 16 Jul 2025 18:14:02 +0200 Subject: [PATCH 41/76] FA duotone --- app/styles/oss-components.less | 1 + 1 file changed, 1 insertion(+) diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less index 217e2d331..40929ab26 100644 --- a/app/styles/oss-components.less +++ b/app/styles/oss-components.less @@ -1,6 +1,7 @@ @import 'shims/bootstrap'; @import (inline) '@fortawesome/fontawesome-pro/css/all.min.css'; +@import (inline) '@fortawesome/fontawesome-pro/css/duotone-regular.min.css'; @import (inline) '@fortawesome/fontawesome-pro/css/v4-shims.min.css'; @import (inline) '@fortawesome/fontawesome-pro/css/duotone-regular.min.css'; From 8130ec36b8fad58b40ce4cb38ba0cc5b59a16ed5 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Thu, 24 Jul 2025 15:25:21 +0200 Subject: [PATCH 42/76] Scrollable-panel: Add new offset params --- .../o-s-s/scrollable-panel.stories.js | 17 ++++++++++-- addon/components/o-s-s/scrollable-panel.ts | 19 +++++++++++--- .../components/o-s-s/scrollable-panel-test.ts | 26 ++++++++++++++++++- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/addon/components/o-s-s/scrollable-panel.stories.js b/addon/components/o-s-s/scrollable-panel.stories.js index 4eb0af811..0c12e8871 100644 --- a/addon/components/o-s-s/scrollable-panel.stories.js +++ b/addon/components/o-s-s/scrollable-panel.stories.js @@ -45,6 +45,16 @@ export default { type: 'boolean' } }, + offset: { + description: 'Offset in pixels from which the scrollable panel will be considered as scrolled', + table: { + type: { summary: 'number' }, + defaultValue: { summary: '0' } + }, + control: { + type: 'number' + } + }, onBottomReached: { description: 'Function to be called when the scroll hits the bottom', table: { @@ -68,6 +78,7 @@ const defaultArgs = { plain: false, disableShadows: false, hideScrollbar: false, + offset: 0, onBottomReached: action('onBottomReached') }; @@ -83,7 +94,8 @@ const Template = (args) => ({ @disableShadows={{this.disableShadows}} @onBottomReached={{this.onBottomReached}} @hideScrollbar={{this.hideScrollbar}} - @horizontal={{this.horizontal}} > + @horizontal={{this.horizontal}} + @offset={{this.offset}} >
@@ -105,7 +117,8 @@ const TemplateHorizontal = (args) => ({ @disableShadows={{this.disableShadows}} @onBottomReached={{this.onBottomReached}} @hideScrollbar={{this.hideScrollbar}} - @horizontal={{this.horizontal}} > + @horizontal={{this.horizontal}} + @offset={{this.offset}} >
diff --git a/addon/components/o-s-s/scrollable-panel.ts b/addon/components/o-s-s/scrollable-panel.ts index 20ba0449b..7dd2187e3 100644 --- a/addon/components/o-s-s/scrollable-panel.ts +++ b/addon/components/o-s-s/scrollable-panel.ts @@ -7,6 +7,7 @@ interface OSSScrollablePanelComponentSignature { disableShadows?: boolean; horizontal?: boolean; hideScrollbar?: boolean; + offset?: number; onBottomReached?: () => void; } @@ -19,6 +20,10 @@ export default class OSSScrollablePanelComponent extends Component 0) { + if (this.parentElement.scrollTop - this.offset > 0) { this.shadowTopVisible = true; } else { this.shadowTopVisible = false; @@ -63,7 +68,10 @@ export default class OSSScrollablePanelComponent extends Component= this.parentElement.scrollHeight - 1) { + if ( + this.parentElement.scrollTop + this.parentElement.clientHeight + this.offset >= + this.parentElement.scrollHeight - 1 + ) { this.shadowBottomVisible = false; } else { this.shadowBottomVisible = true; @@ -71,7 +79,7 @@ export default class OSSScrollablePanelComponent extends Component 0) { + if (this.parentElement.scrollLeft - this.offset > 0) { this.shadowLeftVisible = true; } else { this.shadowLeftVisible = false; @@ -80,7 +88,10 @@ export default class OSSScrollablePanelComponent extends Component= this.parentElement.scrollWidth - 1) { + if ( + this.parentElement.scrollLeft + this.parentElement.clientWidth + this.offset >= + this.parentElement.scrollWidth - 1 + ) { this.shadowRightVisible = false; } else { this.shadowRightVisible = true; diff --git a/tests/integration/components/o-s-s/scrollable-panel-test.ts b/tests/integration/components/o-s-s/scrollable-panel-test.ts index 5db4532ab..b7ded590e 100644 --- a/tests/integration/components/o-s-s/scrollable-panel-test.ts +++ b/tests/integration/components/o-s-s/scrollable-panel-test.ts @@ -10,6 +10,7 @@ module('Integration | Component | o-s-s/scrollable-panel', function (hooks) { hooks.beforeEach(function () { this.onBottomReached = sinon.stub(); this.hideScrollbar = false; + this.offset = 0; }); function scrollIntoView(elementId: string) { @@ -20,7 +21,8 @@ module('Integration | Component | o-s-s/scrollable-panel', function (hooks) {
+ @hideScrollbar={{this.hideScrollbar}} + @offset={{this.offset}} >
@@ -200,4 +202,26 @@ module('Integration | Component | o-s-s/scrollable-panel', function (hooks) { .dom('.oss-scrollable-panel-container .oss-scrollable-panel-content') .hasClass('oss-scrollable-panel-content--hidden'); }); + + module('with @offset', function (hooks) { + hooks.beforeEach(function () { + this.set('offset', 10); + }); + + test('When scrolling under the offset, it does not display top shadow', async function (assert) { + await render(renderScrollableContent); + + assert.dom('.oss-scrollable-panel--shadow__top').doesNotExist(); + await scrollTo('.oss-scrollable-panel-content', 0, 5); + assert.dom('.oss-scrollable-panel--shadow__top').doesNotExist(); + }); + + test('When scrolling above the offset, it displays the top shadow', async function (assert) { + await render(renderScrollableContent); + + assert.dom('.oss-scrollable-panel--shadow__top').doesNotExist(); + await scrollTo('.oss-scrollable-panel-content', 0, 15); + assert.dom('.oss-scrollable-panel--shadow__top').exists(); + }); + }); }); From d8b145d76911579a8fba0d8be9454edb019924fa Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Wed, 23 Jul 2025 16:01:14 +0200 Subject: [PATCH 43/76] Smart immersive select: Add new smart component & add skin on infinite select --- addon/components/o-s-s/infinite-select.hbs | 5 +- .../o-s-s/infinite-select.stories.js | 17 +- addon/components/o-s-s/infinite-select.ts | 5 + addon/components/o-s-s/select.hbs | 2 +- .../o-s-s/smart/immersive/select.hbs | 69 +++++++ .../o-s-s/smart/immersive/select.stories.js | 176 ++++++++++++++++++ .../o-s-s/smart/immersive/select.ts | 127 +++++++++++++ addon/utils/attach-dropdown.ts | 6 + .../o-s-s/smart/immersive/select.js | 1 + app/styles/atoms/smart/immersive-input.less | 5 - app/styles/atoms/smart/immersive-select.less | 101 ++++++++++ app/styles/base/_infinite-select.less | 35 ++++ app/styles/oss-components.less | 1 + tests/dummy/app/controllers/smart.ts | 32 ++++ tests/dummy/app/templates/smart.hbs | 62 +++++- .../components/o-s-s/infinite-select-test.js | 23 +++ .../o-s-s/smart/immersive/select-test.ts | 26 +++ 17 files changed, 683 insertions(+), 10 deletions(-) create mode 100644 addon/components/o-s-s/smart/immersive/select.hbs create mode 100644 addon/components/o-s-s/smart/immersive/select.stories.js create mode 100644 addon/components/o-s-s/smart/immersive/select.ts create mode 100644 app/components/o-s-s/smart/immersive/select.js create mode 100644 app/styles/atoms/smart/immersive-select.less create mode 100644 tests/integration/components/o-s-s/smart/immersive/select-test.ts diff --git a/addon/components/o-s-s/infinite-select.hbs b/addon/components/o-s-s/infinite-select.hbs index 61490c6c9..3f79ab12a 100644 --- a/addon/components/o-s-s/infinite-select.hbs +++ b/addon/components/o-s-s/infinite-select.hbs @@ -1,7 +1,9 @@
@@ -10,6 +12,7 @@ @value={{this._searchKeyword}} @placeholder={{this.searchPlaceholder}} @onChange={{this.updateSearchKeyword}} + class="search-field" {{on "keydown" this.handleKeyEventInput}} {{did-insert this.initSearchInput}} /> diff --git a/addon/components/o-s-s/infinite-select.stories.js b/addon/components/o-s-s/infinite-select.stories.js index b96d5e310..e9b4edcff 100644 --- a/addon/components/o-s-s/infinite-select.stories.js +++ b/addon/components/o-s-s/infinite-select.stories.js @@ -11,6 +11,8 @@ const FAKE_DATA = [ { superhero: 'Spider Man', characters: 'Peter Parker' } ]; +const SkinTypes = ['default', 'smart']; + export default { title: 'Components/OSS::InfiniteSelect', component: 'infinite-select', @@ -56,6 +58,17 @@ export default { }, control: { type: 'boolean' } }, + skin: { + description: 'Adjust the skin of the badge', + table: { + type: { + summary: SkinTypes.join('|') + }, + defaultValue: { summary: 'default' } + }, + options: SkinTypes, + control: { type: 'select' } + }, loading: { type: { name: 'boolean' }, description: 'Whether or not the initial content is loading', @@ -159,6 +172,7 @@ const defaultArgs = { loadingMore: false, inline: false, enableKeyboard: false, + skin: 'default', onSelect: action('onSelect'), onSearch: action('onSearch'), onBottomReached: action('onBottomReached'), @@ -171,7 +185,8 @@ const Template = (args) => ({ @items={{this.items}} @itemLabel={{this.itemLabel}} @searchEnabled={{this.searchEnabled}} @onSearch={{this.onSearch}} @searchPlaceholder={{this.searchPlaceholder}} @onSelect={{this.onSelect}} @loading={{this.loading}} @loadingMore={{this.loadingMore}} @inline={{this.inline}} @onBottomReached={{this.onBottomReached}} - @didRender={{this.didRender}} @enableKeyboard={{this.enableKeyboard}} class="upf-align--absolute-center"/> + @skin={{this.skin}} @didRender={{this.didRender}} @enableKeyboard={{this.enableKeyboard}} + class="upf-align--absolute-center"/> `, context: args }); diff --git a/addon/components/o-s-s/infinite-select.ts b/addon/components/o-s-s/infinite-select.ts index 63cc7e1db..ebef603a5 100644 --- a/addon/components/o-s-s/infinite-select.ts +++ b/addon/components/o-s-s/infinite-select.ts @@ -14,6 +14,7 @@ interface InfiniteSelectArgs { items: InfinityItem[]; inline: boolean; enableKeyboard?: boolean; + skin?: 'default' | 'smart'; onSelect: (item: InfinityItem) => void; onSearch?: (keyword: string) => void; @@ -71,6 +72,10 @@ export default class OSSInfiniteSelect extends Component { return this.args.inline ?? false; } + get skin(): 'default' | 'smart' { + return this.args.skin ?? 'default'; + } + @action onRender(): void { this.args.didRender?.(); diff --git a/addon/components/o-s-s/select.hbs b/addon/components/o-s-s/select.hbs index 27da02a8b..d17eddf32 100644 --- a/addon/components/o-s-s/select.hbs +++ b/addon/components/o-s-s/select.hbs @@ -36,7 +36,7 @@ {{on "click" this.noop}} > <:option as |item index|> -
+
{{#if (has-block "option")}} {{yield item index to="option"}} {{/if}} diff --git a/addon/components/o-s-s/smart/immersive/select.hbs b/addon/components/o-s-s/smart/immersive/select.hbs new file mode 100644 index 000000000..37be6ba02 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/select.hbs @@ -0,0 +1,69 @@ +
+ {{#if @loading}} +
+ {{@placeholder}} +
+ {{else}} + +
+
+
+ {{#each this.values as |selectedItem|}} +
+ {{yield selectedItem to="selected-item"}} +
+ {{else}} + + {{@placeholder}} + + {{/each}} +
+
+
+ {{/if}} + + {{#if this.isOpen}} + {{#in-element this.portalTarget insertBefore=null}} + + <:option as |item|> +
+ {{#if @multiple}} + + {{/if}} + {{yield item to="option-item"}} + {{#if (and (not @multiple) (eq @value item.value))}} + + {{/if}} +
+ +
+ {{/in-element}} + {{/if}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/immersive/select.stories.js b/addon/components/o-s-s/smart/immersive/select.stories.js new file mode 100644 index 000000000..e9c2edaae --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/select.stories.js @@ -0,0 +1,176 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; +import { LOGO_COLORS, LOGO_ICONS } from '../../../../../app/utils/logo-config'; + +export default { + title: 'Components/Smart::Immersive::Select', + component: 'oss-smart-immersive-select', + argTypes: { + placeholder: { + name: 'Placeholder', + description: 'Text displayed when no value is selected.', + table: { defaultValue: { summary: 'undefined' } }, + control: { type: 'text' } + }, + value: { + name: 'Value', + description: 'Selected value for single-select mode.', + table: { defaultValue: { summary: 'undefined' } }, + control: { type: 'text' } + }, + values: { + name: 'Values', + description: 'Selected values for multi-select mode.', + table: { defaultValue: { summary: 'undefined' } }, + control: { type: 'object' } + }, + loading: { + type: { name: 'boolean' }, + description: 'Enable the loading state.', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + multiple: { + type: { name: 'boolean' }, + description: 'Allow multiple selections.', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + items: { + description: 'Array of selectable items, each with a value and label.', + table: { + type: { + summary: '>' + }, + defaultValue: { summary: '[]' } + }, + control: { type: 'array' } + }, + onSearch: { + description: 'Action triggered when the search field is edited.', + table: { + category: 'Actions', + type: { summary: '() => void' } + } + }, + onChange: { + description: 'Action triggered when a value is selected.', + table: { + category: 'Actions', + type: { summary: '() => void' } + } + } + }, + parameters: { + docs: { + description: { + component: + 'A customizable select component for immersive experiences, supporting single or multiple selection, search, and loading states.' + } + } + } +}; + +const Template = ({ iconName, iconColor, ...rest }) => { + const icon = iconName && iconColor ? `${iconName}:${iconColor}` : undefined; + + return { + template: hbs` +
+ + <:selected-item as |item|>{{item}} + <:option-item as |item|> +
+ {{item.label}} +
+ +
+
+ `, + context: { + ...rest, + icon + } + }; +}; + +const TemplateSingle = ({ iconName, iconColor, ...rest }) => { + const icon = iconName && iconColor ? `${iconName}:${iconColor}` : undefined; + + return { + template: hbs` +
+ + <:selected-item as |item|>{{item}} + <:option-item as |item|> +
+ {{item.label}} +
+ +
+
+ `, + context: { + ...rest, + icon + } + }; +}; + +export const Select = TemplateSingle.bind({}); + +Select.args = { + placeholder: 'Placeholder', + value: 'step 1', + loading: false, + multiple: false, + items: [ + { value: 'step 1', label: 'Step 1' }, + { value: 'step 2', label: 'Step 2' }, + { value: 'step 3', label: 'Step 3' } + ], + onSearch: action('onSearch'), + onChange: action('onChange') +}; + +export const Multiple = Template.bind({}); + +Multiple.args = { + placeholder: 'Placeholder', + values: ['step 1', 'step 2'], + loading: false, + multiple: true, + items: [ + { value: 'step 1', label: 'Step 1' }, + { value: 'step 2', label: 'Step 2' }, + { value: 'step 3', label: 'Step 3' } + ], + onSearch: action('onSearch'), + onChange: action('onChange') +}; diff --git a/addon/components/o-s-s/smart/immersive/select.ts b/addon/components/o-s-s/smart/immersive/select.ts new file mode 100644 index 000000000..9f4aca196 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/select.ts @@ -0,0 +1,127 @@ +import { action } from '@ember/object'; +import BaseDropdown, { type BaseDropdownArgs } from '../../private/base-dropdown'; +import { runSmartGradientAnimation } from '@upfluence/oss-components/utils/run-smart-gradient-animation'; +import { isEmpty } from '@ember/utils'; +import { assert } from '@ember/debug'; +import { scheduleOnce } from '@ember/runloop'; +import attachDropdown from '@upfluence/oss-components/utils/attach-dropdown'; +import { helper } from '@ember/component/helper'; + +interface OSSSmartImmersiveSelectComponentSignature extends BaseDropdownArgs { + value?: string; + values?: string[]; + items: { value: string; label: string }[]; + placeholder?: string; + loading?: boolean; + hasError?: boolean; + displayedItems?: number; + maxItemWidth?: number; + addressableAs?: string; + multiple?: boolean; + onChange?: (item: string) => void; + onSearch?: (keyword: string) => void; + onBottomReached?: () => void; +} + +export default class OSSSmartImmersiveSelectComponent extends BaseDropdown { + cleanupDrodpownAutoplacement?: () => void; + + isSelected = helper((_, { value }: { value: string }): boolean => { + return this.args.values?.includes(value) || false; + }); + + get computedClasses(): string { + const classes = ['smart-immersive-select-container']; + if (!isEmpty(this.args.values) || !isEmpty(this.args.value)) { + classes.push('smart-immersive-select-container--filled'); + } + if (this.args.hasError) { + classes.push('smart-immersive-select-container--errored'); + } + return classes.join(' '); + } + + get computedSmartItemStyle(): string { + const style: string[] = []; + if (this.args.maxItemWidth) { + style.push(`max-width: ${this.args.maxItemWidth}px;`); + } + return style.join(' '); + } + + get dropdownAddressableClass(): string { + return this.args.addressableAs ? `${this.args.addressableAs}__dropdown` : ''; + } + + get displayedItems(): number { + return this.args.displayedItems ?? 0; + } + + get values(): string[] { + let values: string[] = []; + if (this.args.multiple) { + values = [...(this.args.values ?? [])]; + } + if (!this.args.multiple && this.args.value) { + values.push(this.args.value); + } + if (this.displayedItems > 0 && values.length > this.displayedItems) { + values = values.slice(0, this.displayedItems); + values.push(`+${(this.args.values?.length ?? 0) - this.displayedItems}`); + } + + return values; + } + + @action + runAnimationOnLoadEnd(): void { + if (this.container && this.args.loading === false && !isEmpty(this.args.values)) { + runSmartGradientAnimation(this.container); + } + } + + @action + onSelect(selection: any): void { + this.args.onChange?.(selection); + } + + @action + handleSelectorClose(): void { + if (!this.container.hasAttribute('open') && document.querySelector(`#${this.portalId}`)) { + document.querySelector(`#${this.portalId}`)!.remove(); + this.cleanupDrodpownAutoplacement?.(); + this.closeDropdown(); + } + } + + @action + ensureBlockPresence(hasSelectedItem: boolean, hasOptionItem: boolean): void | never { + assert(`[component][OSS::PowerSelect] You must pass selected-item named block`, hasSelectedItem); + assert(`[component][OSS::PowerSelect] You must pass option-item named block`, hasOptionItem); + } + + @action + toggleDropdown(event: PointerEvent): void { + super.toggleDropdown(event); + + if (!this.isOpen) { + this.args.onSearch?.(''); + return; + } + + scheduleOnce('afterRender', this, this.setupDropdownAutoplacement); + } + + private setupDropdownAutoplacement(): void { + const referenceTarget = this.container.querySelector('.upf-input'); + const floatingTarget = document.querySelector(`#${this.portalId}`); + + if (referenceTarget && floatingTarget) { + this.cleanupDrodpownAutoplacement = attachDropdown( + referenceTarget as HTMLElement, + floatingTarget as HTMLElement, + { maxHeight: 300, maxWidth: 320, placementStrategy: 'auto' } + ); + } + } +} diff --git a/addon/utils/attach-dropdown.ts b/addon/utils/attach-dropdown.ts index 337d1fbf1..a24bf1e16 100644 --- a/addon/utils/attach-dropdown.ts +++ b/addon/utils/attach-dropdown.ts @@ -16,6 +16,7 @@ export type AttachmentOptions = { offset?: number; width?: number; maxHeight?: number; + maxWidth?: number; placement?: Placement; enableArrow?: boolean; placementStrategy?: 'auto' | 'flip'; @@ -55,6 +56,11 @@ export default function attachDropdown( width: `${desiredWidth}px` }; + if (mergedOptions.maxWidth) { + floatingStyle.width = `fit-content`; + floatingStyle.maxWidth = `${mergedOptions.maxWidth}px`; + } + if (mergedOptions.maxHeight) { floatingStyle.maxHeight = `${floatingStyle.maxHeight}px`; elements.floating.style.setProperty('--floating-max-height', `${mergedOptions.maxHeight}px`); diff --git a/app/components/o-s-s/smart/immersive/select.js b/app/components/o-s-s/smart/immersive/select.js new file mode 100644 index 000000000..348910c5d --- /dev/null +++ b/app/components/o-s-s/smart/immersive/select.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/immersive/select'; diff --git a/app/styles/atoms/smart/immersive-input.less b/app/styles/atoms/smart/immersive-input.less index 109534adc..67a02a896 100644 --- a/app/styles/atoms/smart/immersive-input.less +++ b/app/styles/atoms/smart/immersive-input.less @@ -34,11 +34,6 @@ } } - .smart-immersive--input[type='number']::-webkit-outer-spin-button, - .smart-immersive--input[type='number']::-webkit-inner-spin-button { - color: red; - background-color: rebeccapurple; - } .upf-input.loading-placeholder { padding: 0px; } diff --git a/app/styles/atoms/smart/immersive-select.less b/app/styles/atoms/smart/immersive-select.less new file mode 100644 index 000000000..ec33953ba --- /dev/null +++ b/app/styles/atoms/smart/immersive-select.less @@ -0,0 +1,101 @@ +.smart-immersive-select-container { + width: fit-content; + position: relative; + + .upf-input { + height: 32px; + min-width: 50px; + + border: 1px dashed var(--color-gray-400); + border-radius: var(--border-radius-sm); + display: flex; + align-items: center; + + &.loading-placeholder { + height: 32px; + min-width: 50px; + padding: 0; + pointer-events: none; + border: 1px dashed var(--color-gray-400); + } + + &:focus, + &.active, + &:hover { + border: 1px dashed var(--color-gray-600); + background-color: transparent; + box-shadow: none; + } + } + + &.smart-rotating-gradient { + .upf-input { + border: 1px dashed transparent; + } + } + + &--filled { + .upf-input { + color: var(--color-primary-400); + border-color: var(--color-primary-400); + + &:focus, + &.active { + border: 1px dashed var(--color-primary-400); + } + + &:hover:not(:focus, .active) { + border: 1px dashed var(--color-primary-300); + + .select-smart-item { + color: var(--color-primary-300); + } + } + } + } + + &--errored, + &--error { + .upf-input { + border-color: var(--color-error-500); + + &:hover:not(:focus, .active), + &:focus, + &.active { + border: 1px dashed var(--color-error-500); + } + } + } + + .select-smart-item { + .font-weight-semibold; + .text-ellipsis; + background: var(--color-primary-50); + border-radius: var(--border-radius-sm); + border: 1px solid var(--color-white); + padding: 0 var(--spacing-px-6); + color: var(--color-primary-400); + } + + .smart_text_animated { + background: var(--color-gray-400); + background: linear-gradient( + 130deg, + var(--color-gray-400) 18%, + var(--color-gray-300) 25%, + rgba(250, 198, 255, 1) 56%, + var(--color-primary-100) 62%, + var(--color-white) 66%, + rgba(250, 198, 255, 0.58) 68%, + var(--color-gray-300) 73%, + var(--color-gray-400) 85% + ); + font-size: var(--font-size-md); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: smart_loading_text_animation 3.5s ease-in-out infinite; + background-size: 600% 100%; + padding: var(--spacing-px-6); + } +} diff --git a/app/styles/base/_infinite-select.less b/app/styles/base/_infinite-select.less index 0a5156f2b..4952e9b51 100644 --- a/app/styles/base/_infinite-select.less +++ b/app/styles/base/_infinite-select.less @@ -48,3 +48,38 @@ background-color: var(--color-white); } } + +.upf-infinite-select--smart { + border: 1px solid var(--color-primary-200); + box-shadow: var(--box-shadow-md); + padding: 0; + + .search-field { + margin: var(--spacing-px-12); + margin-bottom: var(--spacing-px-3); + } + + .upf-infinite-select__item { + display: flex; + align-items: center; + color: var(--color-gray-600); + padding: var(--spacing-px-3) var(--spacing-px-12); + height: 36px; + gap: var(--spacing-px-6); + border-radius: 0; + + &:has(> .selected), + &:hover { + background-color: var(--color-gray-100); + color: var(--color-gray-900); + } + + &:not(:first-child)  { + margin-top: 0; + } + + .item-wrapper { + flex: 1; + } + } +} diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less index 40929ab26..2cd89eca4 100644 --- a/app/styles/oss-components.less +++ b/app/styles/oss-components.less @@ -49,6 +49,7 @@ @import 'atoms/pill'; @import 'atoms/smart/immersive-input'; @import 'atoms/smart/immersive-currency-input'; +@import 'atoms/smart/immersive-select'; @import 'atoms/smart/pill'; @import 'atoms/smart/logo'; @import 'molecules/progress-bar'; diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index 1e8560fe4..99b6e6280 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -35,6 +35,20 @@ export default class Smart extends Controller { @tracked selectedPillTwo: boolean = false; intervalId?: number; + @tracked values = ['content']; + @tracked items = [ + { value: 'content', label: 'Jace generated content' }, + { value: '1', label: 'Item 1' }, + { value: '2', label: 'Item 2' }, + { value: '3', label: 'Item 3' }, + { value: '4', label: 'Item 4' }, + { value: '5', label: 'Item 5' }, + { value: '6', label: 'Item 6' }, + { value: '7', label: 'Item 7' }, + { value: '8', label: 'Item 8' }, + { value: '9', label: 'Item 9' } + ]; + constructor() { super(...arguments); this.addContentToFeedbackComponent(); @@ -124,4 +138,22 @@ export default class Smart extends Controller { onLogoClick(): void { console.log('logo icon clicked'); } + + @action + onSearch(keyword: string): void { + console.log('Search keyword:', keyword); + } + + @action + onChange(item: any): void { + console.log('Selected item:', item); + + if (this.values.includes(item.value)) { + this.values = this.values.filter((value) => value !== item.value); + } else { + this.values = [...this.values, item.value]; + } + + this.value = item.value; + } } diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index b983c09f0..cf36cbf60 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -6,6 +6,65 @@ (also they might be smarter than you who knows)
+
+
+ Smart select +
+
+ + + <:selected-item as |item|>{{item}} + <:option-item as |item|> +
+ {{item.label}} +
+ +
+ + <:selected-item as |item|>{{item}} + <:option-item as |item|> +
+ {{item.label}} +
+ +
+ + <:selected-item as |item|>{{item}} + <:option-item as |item|>{{item.label}} + +
+
+
@@ -320,7 +379,6 @@ /> - - +
\ No newline at end of file diff --git a/tests/integration/components/o-s-s/infinite-select-test.js b/tests/integration/components/o-s-s/infinite-select-test.js index 36a450817..3909166cd 100644 --- a/tests/integration/components/o-s-s/infinite-select-test.js +++ b/tests/integration/components/o-s-s/infinite-select-test.js @@ -418,6 +418,29 @@ module('Integration | Component | o-s-s/infinite-select', function (hooks) { }); }); + module('Skin', function (hooks) { + hooks.beforeEach(function () { + this.items = FAKE_DATA; + this.onSelect = () => {}; + }); + + test('it should render with the default skin', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.upf-infinite-select').hasClass('upf-infinite-select--default'); + }); + + test('it should render with the smart skin', async function (assert) { + await render( + hbs`` + ); + + assert.dom('.upf-infinite-select').hasClass('upf-infinite-select--smart'); + }); + }); + module('Error management', function () { module('On item selection, if onSelect is not passed', function () { test('it should throw an error', async function (assert) { diff --git a/tests/integration/components/o-s-s/smart/immersive/select-test.ts b/tests/integration/components/o-s-s/smart/immersive/select-test.ts new file mode 100644 index 000000000..a4dc3ebc0 --- /dev/null +++ b/tests/integration/components/o-s-s/smart/immersive/select-test.ts @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | o-s-s/smart/immersive/select', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function (val) { ... }); + + await render(hbs``); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom().hasText('template block text'); + }); +}); From be20487441f842ad57204b0a51dacabdff7cf717 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Wed, 23 Jul 2025 17:00:46 +0200 Subject: [PATCH 44/76] Smart immersive select: Add tests --- addon/components/o-s-s/select.hbs | 2 +- .../o-s-s/smart/immersive/select.stories.js | 1 - .../o-s-s/smart/immersive/select.ts | 8 +- .../o-s-s/smart/immersive/select-test.ts | 153 ++++++++++++++++-- 4 files changed, 147 insertions(+), 17 deletions(-) diff --git a/addon/components/o-s-s/select.hbs b/addon/components/o-s-s/select.hbs index d17eddf32..27da02a8b 100644 --- a/addon/components/o-s-s/select.hbs +++ b/addon/components/o-s-s/select.hbs @@ -36,7 +36,7 @@ {{on "click" this.noop}} > <:option as |item index|> -
+
{{#if (has-block "option")}} {{yield item index to="option"}} {{/if}} diff --git a/addon/components/o-s-s/smart/immersive/select.stories.js b/addon/components/o-s-s/smart/immersive/select.stories.js index e9c2edaae..0a27ad815 100644 --- a/addon/components/o-s-s/smart/immersive/select.stories.js +++ b/addon/components/o-s-s/smart/immersive/select.stories.js @@ -1,6 +1,5 @@ import { hbs } from 'ember-cli-htmlbars'; import { action } from '@storybook/addon-actions'; -import { LOGO_COLORS, LOGO_ICONS } from '../../../../../app/utils/logo-config'; export default { title: 'Components/Smart::Immersive::Select', diff --git a/addon/components/o-s-s/smart/immersive/select.ts b/addon/components/o-s-s/smart/immersive/select.ts index 9f4aca196..77bb6ab04 100644 --- a/addon/components/o-s-s/smart/immersive/select.ts +++ b/addon/components/o-s-s/smart/immersive/select.ts @@ -30,9 +30,13 @@ export default class OSSSmartImmersiveSelectComponent extends BaseDropdown { + hooks.beforeEach(function () { + this.selectedItems = null; + this.value = 'item1'; + this.multiple = false; + }); + + test('When clicking on the immersive input, it opens the infinite select', async function (assert) { + await renderComponent(); + assert.dom('.upf-infinite-select').doesNotExist(); + await click('.smart-immersive-select-container div'); + assert.dom('.upf-infinite-select').exists().hasClass('upf-infinite-select--smart'); + }); + + test('Selected items are highlighted with a checkmark', async function (assert) { + await renderComponent(); + await click('.smart-immersive-select-container div'); + assert.dom('.upf-infinite-select__item .selected').exists(); + assert.dom('.upf-infinite-select__item .selected i').exists(); + assert.dom('.upf-infinite-select__item .selected i').hasClass('font-color-primary-500').hasClass('fa-check'); + }); + }); + + module('Multiple select', () => { + test('When clicking on the immersive input, it opens the infinite select', async function (assert) { + await renderComponent(); + assert.dom('.upf-infinite-select').doesNotExist(); + await click('.smart-immersive-select-container div'); + assert.dom('.upf-infinite-select').exists().hasClass('upf-infinite-select--smart'); + }); + + test('Selected items are highlighted with an active checkbox', async function (assert) { + await renderComponent(); + await click('.smart-immersive-select-container div'); + + assert.dom('.upf-infinite-select__item:nth-of-type(1) > div').hasClass('selected'); + assert.dom('.upf-infinite-select__item:nth-of-type(1) > div .upf-checkbox input').isChecked(); + assert.dom('.upf-infinite-select__item:nth-of-type(2) > div').hasNoClass('selected'); + assert.dom('.upf-infinite-select__item:nth-of-type(2) > div .upf-checkbox input').isNotChecked(); + }); + + test('When displayedItems is defined, only the X firsts items are displayed, the remaining ones are concatenated', async function (assert) { + this.displayedItems = 1; + this.selectedItems = ['Item 1', 'Item 2', 'Item 3', 'Item 4']; + await renderComponent(); - await render(hbs``); + assert.dom('.smart-immersive-select-container .select-smart-item').exists({ count: 2 }); + assert.dom('.smart-immersive-select-container .select-smart-item:nth-of-type(1)').hasText('Item 1'); + assert.dom('.smart-immersive-select-container .select-smart-item:nth-of-type(2)').exists('+3'); + }); + }); + + test('When maxItemWidth is defined, it applies the max-width style to the selected items', async function (assert) { + await renderComponent(); + assert.dom('.smart-immersive-select-container .select-smart-item').hasStyle({ 'max-width': '200px' }); + }); + + test('When selecting an item in the list, it triggers the onChange action ', async function (assert) { + await renderComponent(); + + assert.ok(this.onChange.notCalled); + await click('.smart-immersive-select-container div'); + await click('.upf-infinite-select__item:nth-of-type(2)'); + assert.ok(this.onChange.calledOnceWith({ value: 'item2', label: 'Item 2' })); + }); - assert.dom().hasText(''); + test('When updating the search input, it triggers the onSearch action ', async function (assert) { + await renderComponent(); - // Template block usage: - await render(hbs` - - template block text - - `); + assert.ok(this.onSearch.notCalled); + await click('.smart-immersive-select-container div'); + await fillIn('.search-field input', 'test'); + assert.ok(this.onSearch.calledOnceWith('test')); + }); + + module('loading', (hooks) => { + hooks.beforeEach(function () { + this.loading = true; + }); + + test('When input is loading, it display an animated div instead of the input', async function (assert) { + await renderComponent(); - assert.dom().hasText('template block text'); + assert.dom('.smart-immersive-select-container .loading-placeholder').exists(); + assert.dom('.smart-immersive-select-container .loading-placeholder').hasText(this.placeholder); + }); + + module('Once loading is finished', () => { + test('It displays an animation once', async function (assert) { + await renderComponent(); + this.set('loading', false); + assert.dom('.smart-immersive-select-container').hasClass('smart-rotating-gradient'); + }); + + test('If the field is empty, it does not display an animation once', async function (assert) { + this.selectedItems = []; + await renderComponent(); + this.set('loading', false); + assert.dom('.smart-immersive-select-container').hasNoClass('smart-rotating-gradient'); + }); + }); }); + + async function renderComponent(): Promise { + return await render( + hbs` + <:selected-item as |item|> + {{item}} + + <:option-item as |item|> + {{item.label}} + + ` + ); + } }); From 2ce89ea7c54d2ca5ba33e00d57830b8621a1f5fa Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Thu, 24 Jul 2025 16:55:06 +0200 Subject: [PATCH 45/76] Fix pr feedback --- addon/components/o-s-s/infinite-select.hbs | 2 +- .../o-s-s/smart/immersive/select.hbs | 5 +- .../o-s-s/smart/immersive/select.stories.js | 22 +++--- .../o-s-s/smart/immersive/select.ts | 21 +++-- app/styles/base/_infinite-select.less | 2 +- tests/dummy/app/templates/smart.hbs | 4 +- .../components/o-s-s/infinite-select-test.js | 2 +- .../o-s-s/smart/immersive/select-test.ts | 77 +++++++++++++++++-- 8 files changed, 98 insertions(+), 37 deletions(-) diff --git a/addon/components/o-s-s/infinite-select.hbs b/addon/components/o-s-s/infinite-select.hbs index 3f79ab12a..32232353c 100644 --- a/addon/components/o-s-s/infinite-select.hbs +++ b/addon/components/o-s-s/infinite-select.hbs @@ -12,7 +12,7 @@ @value={{this._searchKeyword}} @placeholder={{this.searchPlaceholder}} @onChange={{this.updateSearchKeyword}} - class="search-field" + class="upf-infinite-select--search" {{on "keydown" this.handleKeyEventInput}} {{did-insert this.initSearchInput}} /> diff --git a/addon/components/o-s-s/smart/immersive/select.hbs b/addon/components/o-s-s/smart/immersive/select.hbs index 37be6ba02..a03ded53d 100644 --- a/addon/components/o-s-s/smart/immersive/select.hbs +++ b/addon/components/o-s-s/smart/immersive/select.hbs @@ -12,7 +12,6 @@ {{@placeholder}}
{{else}} -
@@ -52,13 +51,13 @@
{{#if @multiple}} {{/if}} {{yield item to="option-item"}} - {{#if (and (not @multiple) (eq @value item.value))}} + {{#if (and (not @multiple) (this.isSelected value=item.value))}} {{/if}}
diff --git a/addon/components/o-s-s/smart/immersive/select.stories.js b/addon/components/o-s-s/smart/immersive/select.stories.js index 0a27ad815..aaeab56f8 100644 --- a/addon/components/o-s-s/smart/immersive/select.stories.js +++ b/addon/components/o-s-s/smart/immersive/select.stories.js @@ -11,19 +11,19 @@ export default { table: { defaultValue: { summary: 'undefined' } }, control: { type: 'text' } }, - value: { - name: 'Value', - description: 'Selected value for single-select mode.', - table: { defaultValue: { summary: 'undefined' } }, - control: { type: 'text' } - }, values: { name: 'Values', description: 'Selected values for multi-select mode.', - table: { defaultValue: { summary: 'undefined' } }, - control: { type: 'object' } + table: { + type: { + summary: '>' + }, + defaultValue: { summary: '[]' } + }, + control: { type: 'array' } }, loading: { + name: 'Loading', type: { name: 'boolean' }, description: 'Enable the loading state.', table: { @@ -35,6 +35,7 @@ export default { control: { type: 'boolean' } }, multiple: { + name: 'Multiple', type: { name: 'boolean' }, description: 'Allow multiple selections.', table: { @@ -46,6 +47,7 @@ export default { control: { type: 'boolean' } }, items: { + name: 'Items', description: 'Array of selectable items, each with a value and label.', table: { type: { @@ -119,7 +121,7 @@ const TemplateSingle = ({ iconName, iconColor, ...rest }) => {
{ + return !isBlank(el) && el !== undefined; + }) ?? []) + ]; + if (this.displayedItems > 0 && values.length > this.displayedItems) { values = values.slice(0, this.displayedItems); values.push(`+${(this.args.values?.length ?? 0) - this.displayedItems}`); @@ -100,8 +99,8 @@ export default class OSSSmartImmersiveSelectComponent extends BaseDropdown {}; }); - test('it should render with the default skin', async function (assert) { + test('When @skin is not specified the "default" skin is applied', async function (assert) { await render( hbs`` ); diff --git a/tests/integration/components/o-s-s/smart/immersive/select-test.ts b/tests/integration/components/o-s-s/smart/immersive/select-test.ts index e527d6c9a..3feb5c124 100644 --- a/tests/integration/components/o-s-s/smart/immersive/select-test.ts +++ b/tests/integration/components/o-s-s/smart/immersive/select-test.ts @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { click, fillIn, render } from '@ember/test-helpers'; +import { click, fillIn, render, setupOnerror } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import sinon from 'sinon'; import { setupIntl } from 'ember-intl/test-support'; @@ -25,10 +25,54 @@ module('Integration | Component | o-s-s/smart/immersive/select', function (hooks this.onChange = sinon.stub(); }); - test('it renders with all required named blocks', async function (assert) { - await renderComponent(); + module('It renders', () => { + test('When missing selected-item name block, it throws an error', async function (assert) { + setupOnerror((err: { message: string }) => { + assert.equal( + err.message, + 'Assertion Failed: [component][OSS::Smart::Immersive::Select] You must pass selected-item named block' + ); + }); + + await render( + hbs` + <:option-item as |item|> + {{item.label}} + + ` + ); + }); + + test('With missing option-item name block, it throws an error', async function (assert) { + setupOnerror((err: { message: string }) => { + assert.equal( + err.message, + 'Assertion Failed: [component][OSS::Smart::Immersive::Select] You must pass option-item named block' + ); + }); + + await render( + hbs` + <:selected-item as |item|> + {{item}} + + ` + ); + }); + + test('With all required named blocks', async function (assert) { + await renderComponent(); - assert.dom('.smart-immersive-select-container').exists(); + assert.dom('.smart-immersive-select-container').exists(); + }); }); module('Single select', (hooks) => { @@ -39,14 +83,14 @@ module('Integration | Component | o-s-s/smart/immersive/select', function (hooks }); test('When clicking on the immersive input, it opens the infinite select', async function (assert) { - await renderComponent(); + await renderSingleComponent(); assert.dom('.upf-infinite-select').doesNotExist(); await click('.smart-immersive-select-container div'); assert.dom('.upf-infinite-select').exists().hasClass('upf-infinite-select--smart'); }); test('Selected items are highlighted with a checkmark', async function (assert) { - await renderComponent(); + await renderSingleComponent(); await click('.smart-immersive-select-container div'); assert.dom('.upf-infinite-select__item .selected').exists(); assert.dom('.upf-infinite-select__item .selected i').exists(); @@ -102,7 +146,7 @@ module('Integration | Component | o-s-s/smart/immersive/select', function (hooks assert.ok(this.onSearch.notCalled); await click('.smart-immersive-select-container div'); - await fillIn('.search-field input', 'test'); + await fillIn('.upf-infinite-select--search input', 'test'); assert.ok(this.onSearch.calledOnceWith('test')); }); @@ -136,7 +180,24 @@ module('Integration | Component | o-s-s/smart/immersive/select', function (hooks async function renderComponent(): Promise { return await render( - hbs` + <:selected-item as |item|> + {{item}} + + <:option-item as |item|> + {{item.label}} + + ` + ); + } + + async function renderSingleComponent(): Promise { + return await render( + hbs` Date: Thu, 24 Jul 2025 17:49:58 +0200 Subject: [PATCH 46/76] Added Smart::Tag & Smart::TagInput components --- addon/components/o-s-s/smart/tag-input.hbs | 36 ++++++ .../o-s-s/smart/tag-input.stories.js | 68 ++++++++++++ addon/components/o-s-s/smart/tag-input.ts | 95 ++++++++++++++++ addon/components/o-s-s/smart/tag.hbs | 15 +++ addon/components/o-s-s/smart/tag.stories.js | 83 ++++++++++++++ addon/components/o-s-s/smart/tag.ts | 68 ++++++++++++ app/components/o-s-s/smart/tag-input.js | 1 + app/components/o-s-s/smart/tag.js | 1 + app/styles/atoms/smart/tag-input.less | 103 ++++++++++++++++++ app/styles/atoms/smart/tag.less | 73 +++++++++++++ app/styles/oss-components.less | 2 + tests/dummy/app/controllers/smart.ts | 48 ++++++++ tests/dummy/app/templates/smart.hbs | 55 ++++++++++ .../components/o-s-s/smart/tag-input-test.ts | 52 +++++++++ .../components/o-s-s/smart/tag-test.ts | 48 ++++++++ translations/en-us.yaml | 3 + 16 files changed, 751 insertions(+) create mode 100644 addon/components/o-s-s/smart/tag-input.hbs create mode 100644 addon/components/o-s-s/smart/tag-input.stories.js create mode 100644 addon/components/o-s-s/smart/tag-input.ts create mode 100644 addon/components/o-s-s/smart/tag.hbs create mode 100644 addon/components/o-s-s/smart/tag.stories.js create mode 100644 addon/components/o-s-s/smart/tag.ts create mode 100644 app/components/o-s-s/smart/tag-input.js create mode 100644 app/components/o-s-s/smart/tag.js create mode 100644 app/styles/atoms/smart/tag-input.less create mode 100644 app/styles/atoms/smart/tag.less create mode 100644 tests/integration/components/o-s-s/smart/tag-input-test.ts create mode 100644 tests/integration/components/o-s-s/smart/tag-test.ts diff --git a/addon/components/o-s-s/smart/tag-input.hbs b/addon/components/o-s-s/smart/tag-input.hbs new file mode 100644 index 000000000..0872a9368 --- /dev/null +++ b/addon/components/o-s-s/smart/tag-input.hbs @@ -0,0 +1,36 @@ +
+
+ {{#if @loading}} +
+ {{or this.inputValue this.placeholder}} +
+ {{else}} +
+ {{if + (or this.inputValue this.isInputFocused) + this.inputValue + this.placeholder + }} + +
+
+ {{this.placeholder}} +
+ {{/if}} + + + +
+
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/tag-input.stories.js b/addon/components/o-s-s/smart/tag-input.stories.js new file mode 100644 index 000000000..30502166a --- /dev/null +++ b/addon/components/o-s-s/smart/tag-input.stories.js @@ -0,0 +1,68 @@ +import { hbs } from 'ember-cli-htmlbars'; + +export default { + title: 'Components/OSS::Smart::TagInput', + component: 'smart-tag-input', + argTypes: { + value: { + description: 'The current value of the input field.', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '' } + }, + control: { type: 'text' } + }, + loading: { + description: 'Whether the input is in a loading state (shows animated overlay).', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + hasError: { + description: 'If true, applies error styling to the input.', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + onKeydown: { + description: + 'Callback triggered when a key is pressed. Called with a `{ value, type }` object when Enter is pressed.', + table: { + category: 'Actions', + type: { summary: '(keyword: { value: string, type: "keyword" | "hashtag" | "mention" }) => void' } + } + } + }, + parameters: { + docs: { + description: { + component: 'A smart tag input component for entering keywords, @mentions, and #hashtags.' + } + } + } +}; + +const defaultArgs = { + value: 'Keyword', + loading: false, + hasError: false +}; + +const Template = (args) => ({ + template: hbs` + + `, + context: args +}); + +export const Default = Template.bind({}); +Default.args = defaultArgs; diff --git a/addon/components/o-s-s/smart/tag-input.ts b/addon/components/o-s-s/smart/tag-input.ts new file mode 100644 index 000000000..379d8aaf0 --- /dev/null +++ b/addon/components/o-s-s/smart/tag-input.ts @@ -0,0 +1,95 @@ +import { action } from '@ember/object'; +import { isBlank } from '@ember/utils'; +import { inject as service } from '@ember/service'; +import type { IntlService } from 'ember-intl'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import type { TagType } from './tag'; + +export type Keyword = { + value: string; + type: TagType; +}; + +interface OSSSmartTagInputArgs { + value: string; + onKeydown(keyword: Keyword): void; + loading?: boolean; + hasError?: boolean; +} + +export default class OSSSmartTagInput extends Component { + @service declare intl: IntlService; + + @tracked inputValue: string = this.args.value || ''; + @tracked isInputFocused: boolean = false; + @tracked declare element: HTMLElement; + + get keywordInputClasses(): string { + const classes = ['tag-input']; + if (this.isInputFocused) { + classes.push('tag-input--focus'); + } + if (isBlank(this.inputValue)) { + classes.push('tag-input--empty'); + } + if (this.args.hasError) { + console.log('input has error'); + classes.push('tag-input--error'); + } + return classes.join(' '); + } + + get placeholder(): string { + return this.intl.t('oss-components.smart.tag_input.placeholder'); + } + + @action + registerElement(element: HTMLElement): void { + this.element = element; + } + + @action + onClickQueryBuilder(): void { + this.focusKeywordInput(); + } + + @action + onFocusin(): void { + this.isInputFocused = true; + } + + @action + onKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === 'Tab') { + this.saveKeyword(); + } + } + + @action + onClickOutside(_: any, event: Event): void { + if (!this.isInputFocused) return; + this.isInputFocused = false; + if (isBlank(this.inputValue)) return; + event.stopImmediatePropagation(); + this.saveKeyword(); + } + + private saveKeyword(): void { + if (this.inputValue.trim().length > 0) { + let type: TagType = 'keyword'; + if (this.inputValue.startsWith('@')) { + type = 'mention'; + } else if (this.inputValue.startsWith('#')) { + type = 'hashtag'; + } + let keyword = { value: this.inputValue.trim(), type }; + this.args.onKeydown(keyword); + this.inputValue = ''; + } + } + + private focusKeywordInput(): void { + (this.element.querySelector('[data-control-name="tag-input"]') as HTMLInputElement)?.focus(); + } +} diff --git a/addon/components/o-s-s/smart/tag.hbs b/addon/components/o-s-s/smart/tag.hbs new file mode 100644 index 000000000..6f82775c8 --- /dev/null +++ b/addon/components/o-s-s/smart/tag.hbs @@ -0,0 +1,15 @@ +
+
+ {{#if this.typeIcon}} + + {{/if}} + {{this.displayLabel}} + {{#if this.closable}} + + {{/if}} +
+
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/tag.stories.js b/addon/components/o-s-s/smart/tag.stories.js new file mode 100644 index 000000000..62957c823 --- /dev/null +++ b/addon/components/o-s-s/smart/tag.stories.js @@ -0,0 +1,83 @@ +import { hbs } from 'ember-cli-htmlbars'; + +export default { + title: 'Components/OSS::Smart::Tag', + component: 'smart-tag', + argTypes: { + label: { + description: 'The text content of the tag.', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + type: { + description: 'The type of tag, which determines color and icon. Defaults to "keyword".', + table: { + type: { summary: '"keyword" | "hashtag" | "mention"' }, + defaultValue: { summary: 'keyword' } + }, + control: { + type: 'select', + options: ['keyword', 'hashtag', 'mention'] + } + }, + size: { + description: 'The size of the tag. "md" (default) or "lg".', + table: { + type: { summary: '"md" | "lg"' }, + defaultValue: { summary: 'md' } + }, + control: { + type: 'select', + options: ['md', 'lg'] + } + }, + successAnimationOnInsertion: { + description: 'If true, plays a success animation when the tag is inserted.', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + onRemove: { + description: 'Callback triggered when the close icon is clicked. If set, the tag is closable.', + table: { + category: 'Actions', + type: { summary: '() => void' } + } + } + }, + parameters: { + docs: { + description: { + component: 'A colored tag component for keywords, hashtags, or mentions.' + } + } + } +}; + +const defaultArgs = { + label: 'Tag', + type: 'keyword', + size: 'md', + successAnimationOnInsertion: false +}; + +const Template = (args) => ({ + template: hbs` + + `, + context: args +}); + +export const Default = Template.bind({}); +Default.args = defaultArgs; diff --git a/addon/components/o-s-s/smart/tag.ts b/addon/components/o-s-s/smart/tag.ts new file mode 100644 index 000000000..efdb8c02b --- /dev/null +++ b/addon/components/o-s-s/smart/tag.ts @@ -0,0 +1,68 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { runSmartGradientAnimation } from '@upfluence/oss-components/utils/run-smart-gradient-animation'; + +export type TagType = 'keyword' | 'hashtag' | 'mention'; + +const TypeColorMatch: any = { + hashtag: 'cyan', + mention: 'violet', + keyword: 'yellow' +}; + +const TypeIconMatch: any = { + hashtag: 'fa-hashtag', + mention: 'fa-at', + keyword: null +}; + +interface OSSSmartTagArgs { + label: string; + type?: TagType; + size?: 'md' | 'lg'; + successAnimationOnInsertion?: boolean; + onRemove?(): void; +} + +export default class OSSSmartTag extends Component { + @tracked declare element: HTMLElement; + + get closable(): boolean { + return Boolean(this.args.onRemove); + } + + get typeColor(): TagType { + return TypeColorMatch[this.args.type || 'keyword']; + } + + get typeIcon(): TagType { + return TypeIconMatch[this.args.type || 'keyword']; + } + + get displayLabel(): string { + if (this.args.label[0] === '@' || this.args.label[0] === '#') { + return this.args.label.slice(1); + } + return this.args.label; + } + + @action + onRemove(event?: PointerEvent): void { + event?.stopPropagation(); + this.args.onRemove?.(); + } + + @action + handleElementLifecycle(element: HTMLElement): void { + this.element = element; + this.runAnimationOnLoadEnd(); + } + + @action + runAnimationOnLoadEnd(): void { + if (this.element && this.args.successAnimationOnInsertion) { + runSmartGradientAnimation(this.element); + } + } +} diff --git a/app/components/o-s-s/smart/tag-input.js b/app/components/o-s-s/smart/tag-input.js new file mode 100644 index 000000000..eaccfeba3 --- /dev/null +++ b/app/components/o-s-s/smart/tag-input.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/tag-input'; diff --git a/app/components/o-s-s/smart/tag.js b/app/components/o-s-s/smart/tag.js new file mode 100644 index 000000000..eb46e3faf --- /dev/null +++ b/app/components/o-s-s/smart/tag.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/smart/tag'; diff --git a/app/styles/atoms/smart/tag-input.less b/app/styles/atoms/smart/tag-input.less new file mode 100644 index 000000000..93a7db390 --- /dev/null +++ b/app/styles/atoms/smart/tag-input.less @@ -0,0 +1,103 @@ +.tag-input-container { + display: flex; + align-items: center; + width: auto; + height: 30px; + border: 1px dashed var(--color-gray-400); + border-radius: var(--border-radius-xl); + padding: 0 var(--spacing-px-12); + color: var(--color-gray-400); + background-color: var(--color-white); + + .tag-input { + width: min-content; + display: flex; + align-items: center; + gap: var(--spacing-px-6); + overflow: hidden; + position: relative; + + * { + font-weight: var(--font-weight-semibold); + } + + .hidden-span { + height: 0px; + display: block; + visibility: hidden; + width: fit-content; + white-space: pre; + transition: all 250ms ease-in-out; + } + + .displayed-input { + width: 100%; + min-width: 4px; + outline: none; + border: none; + padding: 0; + } + + .tag-input-empty-state { + display: inline; + overflow: hidden; + white-space: nowrap; + pointer-events: none; + position: absolute; + transition: all 250ms ease-in-out; + } + + &:focus-within { + transition: all 250ms ease-in-out; + + .tag-input-empty-state { + display: none; + max-width: 0; + transition: all 250ms ease-in-out; + } + } + } + + &--active { + .tag-input-empty-state { + display: none; + max-width: 0; + transition: all 250ms ease-in-out; + } + } + + &:has(.tag-input--error) { + border-color: var(--color-error-500); + } +} + +.animated-overlay { + background: linear-gradient( + 130deg, + var(--color-gray-400) 18%, + var(--color-gray-300) 25%, + rgba(250, 198, 255, 1) 56%, + var(--color-primary-100) 62%, + var(--color-white) 66%, + rgba(250, 198, 255, 0.58) 68%, + var(--color-gray-300) 73%, + var(--color-gray-400) 85% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: smart_loading_text_animation 3.5s ease-in-out infinite; + background-size: 600% 100%; + width: fit-content; + overflow: hidden; + white-space: nowrap; +} + +@keyframes smart_loading_text_animation-text { + 0% { + background-position: 100% 0; + } + 100% { + background-position: 0 0; + } +} diff --git a/app/styles/atoms/smart/tag.less b/app/styles/atoms/smart/tag.less new file mode 100644 index 000000000..367899e32 --- /dev/null +++ b/app/styles/atoms/smart/tag.less @@ -0,0 +1,73 @@ +.smart-tag { + display: flex; + align-items: center; + gap: var(--spacing-px-6); + padding: 0 var(--spacing-px-12); + border-radius: var(--border-radius-xl); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + background-color: var(--color-white); + height: 30px; + transition: 250ms ease-in-out; + + &--color-yellow { + border: 1px solid var(--color-yellow-500); + color: var(--color-yellow-500); + + &:hover { + border: 1px solid var(--color-yellow-600); + color: var(--color-yellow-600); + } + + &:active { + border: 1px solid var(--color-yellow-400); + color: var(--color-yellow-400); + } + } + + &--color-cyan { + border: 1px solid var(--color-cyan-500); + color: var(--color-cyan-500); + + &:hover { + border: 1px solid var(--color-cyan-600); + color: var(--color-cyan-600); + } + + &:active { + border: 1px solid var(--color-cyan-400); + color: var(--color-cyan-400); + } + } + + &--color-violet { + border: 1px solid var(--color-violet-500); + color: var(--color-violet-500); + + &:hover { + border: 1px solid var(--color-violet-600); + color: var(--color-violet-600); + } + + &:active { + border: 1px solid var(--color-violet-400); + color: var(--color-violet-400); + } + } +} + +.smart-tag-container.smart-rotating-gradient { + &::after { + border-radius: var(--border-radius-xl); + } + + .smart-tag { + border: 1px solid transparent; + } +} + +.smart-tag-container--lg { + .smart-tag { + height: 40px; + } +} diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less index 2cd89eca4..6c56e45a4 100644 --- a/app/styles/oss-components.less +++ b/app/styles/oss-components.less @@ -52,6 +52,8 @@ @import 'atoms/smart/immersive-select'; @import 'atoms/smart/pill'; @import 'atoms/smart/logo'; +@import 'atoms/smart/tag'; +@import 'atoms/smart/tag-input'; @import 'molecules/progress-bar'; @import 'molecules/select'; @import 'molecules/nav-tab'; diff --git a/tests/dummy/app/controllers/smart.ts b/tests/dummy/app/controllers/smart.ts index 99b6e6280..35c0bba98 100644 --- a/tests/dummy/app/controllers/smart.ts +++ b/tests/dummy/app/controllers/smart.ts @@ -1,6 +1,9 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; +import { next } from '@ember/runloop'; import { tracked } from '@glimmer/tracking'; +import type { TagType } from '@upfluence/oss-components/components/o-s-s/smart/tag'; +import type { Keyword } from '@upfluence/oss-components/components/o-s-s/smart/tag-input'; export default class Smart extends Controller { @tracked selectedToggle: string = 'first'; @@ -48,6 +51,14 @@ export default class Smart extends Controller { { value: '8', label: 'Item 8' }, { value: '9', label: 'Item 9' } ]; + @tracked tagLoading: boolean = false; + @tracked tags: { value: string; type: TagType }[] = [ + { value: 'keyword', type: 'keyword' }, + { value: 'mention', type: 'mention' }, + { value: 'hashtag', type: 'hashtag' } + ]; + @tracked smartTags: { value: string; type: TagType }[] = []; + @tracked inputValue: string = ''; constructor() { super(...arguments); @@ -156,4 +167,41 @@ export default class Smart extends Controller { this.value = item.value; } + + @action + onTagRemove(): void { + console.log('removing tag'); + } + + @action + toggleTagAnimation(): void { + this.tagLoading = !this.tagLoading; + } + + @action + handleTagInput({ value, type }: Keyword): string { + this.tags = [...this.tags, { value, type }]; + return ''; + } + + @action + handleSmartTagInput({ value, type }: Keyword): string { + this.smartTags = [...this.smartTags, { value, type }]; + return ''; + } + + @action + fakeLoadData(): void { + this.loading = true; + setTimeout(() => { + this.smartTags = [ + { value: 'example', type: 'keyword' }, + { value: 'test', type: 'mention' }, + { value: 'sample', type: 'hashtag' } + ]; + next(() => { + this.loading = false; + }); + }, 3000); + } } diff --git a/tests/dummy/app/templates/smart.hbs b/tests/dummy/app/templates/smart.hbs index f45a846d5..b857a14b3 100644 --- a/tests/dummy/app/templates/smart.hbs +++ b/tests/dummy/app/templates/smart.hbs @@ -6,6 +6,61 @@ (also they might be smarter than you who knows)
+
+
+ Smart tag & Smart tag input +
+
+
+ + + {{#each this.tags as |tag|}} + + {{/each}} + {{#each this.tags as |tag|}} + + {{/each}} +
+
+ {{#each this.smartTags as |smartTag|}} + + {{/each}} + + + +
+
+ + {{#each this.smartTags as |smartTag|}} + + {{/each}} + +
+
+
+
diff --git a/tests/integration/components/o-s-s/smart/tag-input-test.ts b/tests/integration/components/o-s-s/smart/tag-input-test.ts new file mode 100644 index 000000000..206819a7d --- /dev/null +++ b/tests/integration/components/o-s-s/smart/tag-input-test.ts @@ -0,0 +1,52 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, triggerKeyEvent, fillIn } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import sinon from 'sinon'; +import { setupIntl } from 'ember-intl/test-support'; + +module('Integration | Component | o-s-s/smart/tag-input', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + hooks.beforeEach(function () { + this.onKeydown = sinon.stub(); + }); + + test('it renders', async function (assert) { + await render(hbs``); + assert.dom('.tag-input-container').exists(); + }); + + test('it renders the empty state with placeholder', async function (assert) { + await render(hbs``); + assert + .dom('.tag-input-empty-state') + .hasText(this.intl.t('oss-components.smart.tag_input.placeholder'), 'Placeholder text is rendered'); + }); + + test('it calls @onKeydown when pressing Enter', async function (assert) { + await render(hbs``); + await fillIn('[data-control-name="tag-input"]', 'foo'); + await triggerKeyEvent('[data-control-name="tag-input"]', 'keydown', 'Enter'); + assert.true(this.onKeydown.calledOnce, 'onKeydown is called once'); + }); + + test('input is cleared after save', async function (assert) { + await render(hbs``); + await fillIn('[data-control-name="tag-input"]', 'foo'); + assert.dom('[data-control-name="tag-input"]').hasValue('foo', 'Input has value "foo"'); + await triggerKeyEvent('[data-control-name="tag-input"]', 'keydown', 'Enter'); + assert.dom('[data-control-name="tag-input"]').hasValue('', 'Input is cleared after save'); + }); + + test('it renders the animated overlay when loading', async function (assert) { + await render(hbs``); + assert.dom('.animated-overlay').exists('Animated overlay is shown'); + }); + + test('it applies the error class when @hasError is true', async function (assert) { + await render(hbs``); + assert.dom('.tag-input--error').exists('Error class is present'); + }); +}); diff --git a/tests/integration/components/o-s-s/smart/tag-test.ts b/tests/integration/components/o-s-s/smart/tag-test.ts new file mode 100644 index 000000000..b9f03d441 --- /dev/null +++ b/tests/integration/components/o-s-s/smart/tag-test.ts @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import sinon from 'sinon'; + +module('Integration | Component | o-s-s/smart/tag', function (hooks) { + setupRenderingTest(hooks); + + test('it renders with default (keyword) type', async function (assert) { + await render(hbs``); + assert.dom('.smart-tag').hasClass('smart-tag--color-yellow', 'Has yellow color for keyword'); + assert.dom('.smart-tag .fa-hashtag, .smart-tag .fa-at').doesNotExist('No icon for keyword'); + assert.dom('.smart-tag span').hasText('foo', 'Displays label'); + }); + + test('it renders with hashtag type', async function (assert) { + await render(hbs``); + assert.dom('.smart-tag').hasClass('smart-tag--color-cyan', 'Has cyan color for hashtag'); + assert.dom('.smart-tag .fa-hashtag').exists('Has hashtag icon'); + assert.dom('.smart-tag span').hasText('bar'); + }); + + test('it renders with mention type', async function (assert) { + await render(hbs``); + assert.dom('.smart-tag').hasClass('smart-tag--color-violet', 'Has violet color for mention'); + assert.dom('.smart-tag .fa-at').exists('Has at icon'); + assert.dom('.smart-tag span').hasText('baz'); + }); + + test('it renders a closable tag and triggers onRemove', async function (assert) { + this.onRemove = sinon.stub(); + await render(hbs``); + assert.dom('[data-control-name="remove-tag-button"]').exists('Remove button is rendered'); + await click('[data-control-name="remove-tag-button"]'); + assert.ok(this.onRemove.calledOnce, 'onRemove action is called once'); + }); + + test('it applies the correct size class', async function (assert) { + await render(hbs``); + assert.dom('.smart-tag-container').hasClass('smart-tag-container--lg', 'Has large size class'); + }); + + test('it applies success animation class when @successAnimationOnInsertion is truthy', async function (assert) { + await render(hbs``); + assert.dom('.smart-tag-container').hasClass('smart-rotating-gradient'); + }); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 42487cacf..23299a801 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -77,3 +77,6 @@ oss-components: forms: errors: required: This field is required. + smart: + tag_input: + placeholder: "Add keywords, @mentions and #hashtags" From 6379101be54dac5f1542117dca9a3836845100fe Mon Sep 17 00:00:00 2001 From: Nathalie Date: Thu, 24 Jul 2025 18:02:39 +0200 Subject: [PATCH 47/76] Fixed tag lg size --- app/styles/atoms/smart/tag.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/styles/atoms/smart/tag.less b/app/styles/atoms/smart/tag.less index 367899e32..20b64583a 100644 --- a/app/styles/atoms/smart/tag.less +++ b/app/styles/atoms/smart/tag.less @@ -68,6 +68,6 @@ .smart-tag-container--lg { .smart-tag { - height: 40px; + height: 36px; } } From 05b73985fe8f9d7efa9bec47b2fc0c20a96757c2 Mon Sep 17 00:00:00 2001 From: Nathalie Date: Fri, 25 Jul 2025 15:39:54 +0200 Subject: [PATCH 48/76] Fixed PR feedback --- addon/components/o-s-s/smart/tag-input.hbs | 8 ++- addon/components/o-s-s/smart/tag-input.ts | 13 ++--- addon/components/o-s-s/smart/tag.hbs | 2 +- addon/components/o-s-s/smart/tag.ts | 34 +++++++----- app/styles/atoms/smart/tag-input.less | 52 +++++++++---------- app/styles/atoms/smart/tag.less | 7 ++- .../components/o-s-s/smart/tag-input-test.ts | 2 +- .../components/o-s-s/smart/tag-test.ts | 19 ++++++- 8 files changed, 83 insertions(+), 54 deletions(-) diff --git a/addon/components/o-s-s/smart/tag-input.hbs b/addon/components/o-s-s/smart/tag-input.hbs index 0872a9368..60dc39551 100644 --- a/addon/components/o-s-s/smart/tag-input.hbs +++ b/addon/components/o-s-s/smart/tag-input.hbs @@ -10,11 +10,9 @@
{{else}}
- {{if - (or this.inputValue this.isInputFocused) - this.inputValue - this.placeholder - }} + + {{if (or this.inputValue this.isInputFocused) this.inputValue this.placeholder}} + { @@ -23,7 +24,7 @@ export default class OSSSmartTagInput extends Component { @tracked inputValue: string = this.args.value || ''; @tracked isInputFocused: boolean = false; - @tracked declare element: HTMLElement; + declare element: HTMLElement; get keywordInputClasses(): string { const classes = ['tag-input']; @@ -41,7 +42,7 @@ export default class OSSSmartTagInput extends Component { } get placeholder(): string { - return this.intl.t('oss-components.smart.tag_input.placeholder'); + return this.args.placeholder || this.intl.t('oss-components.smart.tag_input.placeholder'); } @action @@ -62,7 +63,7 @@ export default class OSSSmartTagInput extends Component { @action onKeydown(event: KeyboardEvent): void { if (event.key === 'Enter' || event.key === 'Tab') { - this.saveKeyword(); + this.formatAndNotify(); } } @@ -72,10 +73,10 @@ export default class OSSSmartTagInput extends Component { this.isInputFocused = false; if (isBlank(this.inputValue)) return; event.stopImmediatePropagation(); - this.saveKeyword(); + this.formatAndNotify(); } - private saveKeyword(): void { + private formatAndNotify(): void { if (this.inputValue.trim().length > 0) { let type: TagType = 'keyword'; if (this.inputValue.startsWith('@')) { @@ -83,7 +84,7 @@ export default class OSSSmartTagInput extends Component { } else if (this.inputValue.startsWith('#')) { type = 'hashtag'; } - let keyword = { value: this.inputValue.trim(), type }; + const keyword = { value: this.inputValue.trim(), type }; this.args.onKeydown(keyword); this.inputValue = ''; } diff --git a/addon/components/o-s-s/smart/tag.hbs b/addon/components/o-s-s/smart/tag.hbs index 6f82775c8..5defd8522 100644 --- a/addon/components/o-s-s/smart/tag.hbs +++ b/addon/components/o-s-s/smart/tag.hbs @@ -1,5 +1,5 @@
diff --git a/addon/components/o-s-s/smart/tag.ts b/addon/components/o-s-s/smart/tag.ts index efdb8c02b..a9a04ade7 100644 --- a/addon/components/o-s-s/smart/tag.ts +++ b/addon/components/o-s-s/smart/tag.ts @@ -5,16 +5,24 @@ import { runSmartGradientAnimation } from '@upfluence/oss-components/utils/run-s export type TagType = 'keyword' | 'hashtag' | 'mention'; -const TypeColorMatch: any = { - hashtag: 'cyan', - mention: 'violet', - keyword: 'yellow' -}; +export type TagTypeDefinition = { icon?: string; color: string }; + +// const TypeColorMatch: any = { +// hashtag: 'cyan', +// mention: 'violet', +// keyword: 'yellow' +// }; + +// const TypeIconMatch: any = { +// hashtag: 'fa-hashtag', +// mention: 'fa-at', +// keyword: null +// }; -const TypeIconMatch: any = { - hashtag: 'fa-hashtag', - mention: 'fa-at', - keyword: null +const TypeDefinition: Record = { + hashtag: { icon: 'fa-hashtag', color: 'cyan' }, + mention: { icon: 'fa-at', color: 'violet' }, + keyword: { icon: 'x', color: 'yellow' } }; interface OSSSmartTagArgs { @@ -32,12 +40,12 @@ export default class OSSSmartTag extends Component { return Boolean(this.args.onRemove); } - get typeColor(): TagType { - return TypeColorMatch[this.args.type || 'keyword']; + get typeColor(): string { + return TypeDefinition[this.args.type ?? 'keyword'].color; } - get typeIcon(): TagType { - return TypeIconMatch[this.args.type || 'keyword']; + get typeIcon(): string | undefined { + return TypeDefinition[this.args.type ?? 'keyword'].icon; } get displayLabel(): string { diff --git a/app/styles/atoms/smart/tag-input.less b/app/styles/atoms/smart/tag-input.less index 93a7db390..f4a49bcc0 100644 --- a/app/styles/atoms/smart/tag-input.less +++ b/app/styles/atoms/smart/tag-input.less @@ -17,10 +17,6 @@ overflow: hidden; position: relative; - * { - font-weight: var(--font-weight-semibold); - } - .hidden-span { height: 0px; display: block; @@ -28,6 +24,7 @@ width: fit-content; white-space: pre; transition: all 250ms ease-in-out; + font-weight: var(--font-weight-semibold); } .displayed-input { @@ -36,6 +33,7 @@ outline: none; border: none; padding: 0; + font-weight: var(--font-weight-semibold); } .tag-input-empty-state { @@ -45,6 +43,30 @@ pointer-events: none; position: absolute; transition: all 250ms ease-in-out; + font-weight: var(--font-weight-semibold); + } + + .animated-overlay { + font-weight: var(--font-weight-semibold); + background: linear-gradient( + 130deg, + var(--color-gray-400) 18%, + var(--color-gray-300) 25%, + rgba(250, 198, 255, 1) 56%, + var(--color-primary-100) 62%, + var(--color-white) 66%, + rgba(250, 198, 255, 0.58) 68%, + var(--color-gray-300) 73%, + var(--color-gray-400) 85% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: smart_loading_text_animation 3.5s ease-in-out infinite; + background-size: 600% 100%; + width: fit-content; + overflow: hidden; + white-space: nowrap; } &:focus-within { @@ -71,28 +93,6 @@ } } -.animated-overlay { - background: linear-gradient( - 130deg, - var(--color-gray-400) 18%, - var(--color-gray-300) 25%, - rgba(250, 198, 255, 1) 56%, - var(--color-primary-100) 62%, - var(--color-white) 66%, - rgba(250, 198, 255, 0.58) 68%, - var(--color-gray-300) 73%, - var(--color-gray-400) 85% - ); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - animation: smart_loading_text_animation 3.5s ease-in-out infinite; - background-size: 600% 100%; - width: fit-content; - overflow: hidden; - white-space: nowrap; -} - @keyframes smart_loading_text_animation-text { 0% { background-position: 100% 0; diff --git a/app/styles/atoms/smart/tag.less b/app/styles/atoms/smart/tag.less index 20b64583a..5b40eb2ca 100644 --- a/app/styles/atoms/smart/tag.less +++ b/app/styles/atoms/smart/tag.less @@ -7,7 +7,6 @@ font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); background-color: var(--color-white); - height: 30px; transition: 250ms ease-in-out; &--color-yellow { @@ -66,6 +65,12 @@ } } +.smart-tag-container--md { + .smart-tag { + height: 30px; + } +} + .smart-tag-container--lg { .smart-tag { height: 36px; diff --git a/tests/integration/components/o-s-s/smart/tag-input-test.ts b/tests/integration/components/o-s-s/smart/tag-input-test.ts index 206819a7d..92ab002ed 100644 --- a/tests/integration/components/o-s-s/smart/tag-input-test.ts +++ b/tests/integration/components/o-s-s/smart/tag-input-test.ts @@ -32,7 +32,7 @@ module('Integration | Component | o-s-s/smart/tag-input', function (hooks) { assert.true(this.onKeydown.calledOnce, 'onKeydown is called once'); }); - test('input is cleared after save', async function (assert) { + test('input is cleared on validation', async function (assert) { await render(hbs``); await fillIn('[data-control-name="tag-input"]', 'foo'); assert.dom('[data-control-name="tag-input"]').hasValue('foo', 'Input has value "foo"'); diff --git a/tests/integration/components/o-s-s/smart/tag-test.ts b/tests/integration/components/o-s-s/smart/tag-test.ts index b9f03d441..b6351e833 100644 --- a/tests/integration/components/o-s-s/smart/tag-test.ts +++ b/tests/integration/components/o-s-s/smart/tag-test.ts @@ -4,6 +4,8 @@ import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import sinon from 'sinon'; +const SIZES = ['md', 'lg']; + module('Integration | Component | o-s-s/smart/tag', function (hooks) { setupRenderingTest(hooks); @@ -28,7 +30,7 @@ module('Integration | Component | o-s-s/smart/tag', function (hooks) { assert.dom('.smart-tag span').hasText('baz'); }); - test('it renders a closable tag and triggers onRemove', async function (assert) { + test('clicking on the close button calls the @onRemove method', async function (assert) { this.onRemove = sinon.stub(); await render(hbs``); assert.dom('[data-control-name="remove-tag-button"]').exists('Remove button is rendered'); @@ -41,6 +43,21 @@ module('Integration | Component | o-s-s/smart/tag', function (hooks) { assert.dom('.smart-tag-container').hasClass('smart-tag-container--lg', 'Has large size class'); }); + module('size attribute', () => { + test(`it applies the default size class when no size is specified`, async function (assert) { + await render(hbs``); + assert.dom('.smart-tag-container').hasClass('smart-tag-container--md', 'Has default medium size class'); + }); + + SIZES.forEach((size) => { + test(`it applies the ${size} size class`, async function (assert) { + this.size = size; + await render(hbs``); + assert.dom('.smart-tag-container').hasClass(`smart-tag-container--${size}`, `Has ${size} size class`); + }); + }); + }); + test('it applies success animation class when @successAnimationOnInsertion is truthy', async function (assert) { await render(hbs``); assert.dom('.smart-tag-container').hasClass('smart-rotating-gradient'); From a7755614d0770c7cd2e9f1c36792077618a2279d Mon Sep 17 00:00:00 2001 From: Nathalie Date: Fri, 25 Jul 2025 15:56:52 +0200 Subject: [PATCH 49/76] Added test and doc for placeholder arg on tag input --- addon/components/o-s-s/smart/tag-input.stories.js | 13 ++++++++++++- .../components/o-s-s/smart/tag-input-test.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/addon/components/o-s-s/smart/tag-input.stories.js b/addon/components/o-s-s/smart/tag-input.stories.js index 30502166a..2895b6694 100644 --- a/addon/components/o-s-s/smart/tag-input.stories.js +++ b/addon/components/o-s-s/smart/tag-input.stories.js @@ -35,6 +35,16 @@ export default { category: 'Actions', type: { summary: '(keyword: { value: string, type: "keyword" | "hashtag" | "mention" }) => void' } } + }, + placeholder: { + description: 'The placeholder to show when the input is empty', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: undefined } + }, + control: { type: 'text' } } }, parameters: { @@ -49,7 +59,8 @@ export default { const defaultArgs = { value: 'Keyword', loading: false, - hasError: false + hasError: false, + placeholder: 'Type a keyword, mention, or hashtag' }; const Template = (args) => ({ diff --git a/tests/integration/components/o-s-s/smart/tag-input-test.ts b/tests/integration/components/o-s-s/smart/tag-input-test.ts index 92ab002ed..7192fb157 100644 --- a/tests/integration/components/o-s-s/smart/tag-input-test.ts +++ b/tests/integration/components/o-s-s/smart/tag-input-test.ts @@ -18,13 +18,20 @@ module('Integration | Component | o-s-s/smart/tag-input', function (hooks) { assert.dom('.tag-input-container').exists(); }); - test('it renders the empty state with placeholder', async function (assert) { + test('it renders the empty state with the default placeholder', async function (assert) { await render(hbs``); assert .dom('.tag-input-empty-state') .hasText(this.intl.t('oss-components.smart.tag_input.placeholder'), 'Placeholder text is rendered'); }); + test('it displays the custom placeholder when @placeholder is provided', async function (assert) { + await render( + hbs`` + ); + assert.dom('.tag-input-empty-state').hasText('Custom Placeholder', 'Custom placeholder is displayed'); + }); + test('it calls @onKeydown when pressing Enter', async function (assert) { await render(hbs``); await fillIn('[data-control-name="tag-input"]', 'foo'); @@ -32,7 +39,7 @@ module('Integration | Component | o-s-s/smart/tag-input', function (hooks) { assert.true(this.onKeydown.calledOnce, 'onKeydown is called once'); }); - test('input is cleared on validation', async function (assert) { + test('the input is cleared on validation', async function (assert) { await render(hbs``); await fillIn('[data-control-name="tag-input"]', 'foo'); assert.dom('[data-control-name="tag-input"]').hasValue('foo', 'Input has value "foo"'); From a23a8e01d73cd0a5b54d860e77833022c12e06e6 Mon Sep 17 00:00:00 2001 From: Nathalie Date: Fri, 25 Jul 2025 16:16:34 +0200 Subject: [PATCH 50/76] Fixed additional PR feedback --- addon/components/o-s-s/smart/tag-input.ts | 2 +- addon/components/o-s-s/smart/tag.ts | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/addon/components/o-s-s/smart/tag-input.ts b/addon/components/o-s-s/smart/tag-input.ts index 0c5392fff..15127ba45 100644 --- a/addon/components/o-s-s/smart/tag-input.ts +++ b/addon/components/o-s-s/smart/tag-input.ts @@ -42,7 +42,7 @@ export default class OSSSmartTagInput extends Component { } get placeholder(): string { - return this.args.placeholder || this.intl.t('oss-components.smart.tag_input.placeholder'); + return this.args.placeholder ?? this.intl.t('oss-components.smart.tag_input.placeholder'); } @action diff --git a/addon/components/o-s-s/smart/tag.ts b/addon/components/o-s-s/smart/tag.ts index a9a04ade7..3f093854e 100644 --- a/addon/components/o-s-s/smart/tag.ts +++ b/addon/components/o-s-s/smart/tag.ts @@ -4,21 +4,8 @@ import { tracked } from '@glimmer/tracking'; import { runSmartGradientAnimation } from '@upfluence/oss-components/utils/run-smart-gradient-animation'; export type TagType = 'keyword' | 'hashtag' | 'mention'; - export type TagTypeDefinition = { icon?: string; color: string }; -// const TypeColorMatch: any = { -// hashtag: 'cyan', -// mention: 'violet', -// keyword: 'yellow' -// }; - -// const TypeIconMatch: any = { -// hashtag: 'fa-hashtag', -// mention: 'fa-at', -// keyword: null -// }; - const TypeDefinition: Record = { hashtag: { icon: 'fa-hashtag', color: 'cyan' }, mention: { icon: 'fa-at', color: 'violet' }, From cccb9efacc93fc161a9144aac675675e391ef4dd Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Tue, 12 Aug 2025 12:15:15 +0200 Subject: [PATCH 51/76] Smart component: Fix input css issue --- .../o-s-s/smart/immersive/select.hbs | 1 + .../o-s-s/smart/immersive/select.ts | 1 + addon/components/o-s-s/smart/input.hbs | 4 +- app/styles/atoms/smart-input.less | 46 +++++++++++-------- app/styles/oss-components.less | 2 + tests/dummy/app/templates/smart.hbs | 3 -- .../components/o-s-s/smart/input-test.ts | 2 +- 7 files changed, 35 insertions(+), 24 deletions(-) diff --git a/addon/components/o-s-s/smart/immersive/select.hbs b/addon/components/o-s-s/smart/immersive/select.hbs index a03ded53d..71108a307 100644 --- a/addon/components/o-s-s/smart/immersive/select.hbs +++ b/addon/components/o-s-s/smart/immersive/select.hbs @@ -42,6 +42,7 @@ @loadingMore={{@loadingMore}} @onBottomReached={{@onBottomReached}} @enableKeyboard={{true}} + @searchEnabled={{@searchEnabled}} class={{concat "margin-top-px-0 upf-power-select__dropdown " this.dropdownAddressableClass}} id={{this.portalId}} {{on "click" this.noop}} diff --git a/addon/components/o-s-s/smart/immersive/select.ts b/addon/components/o-s-s/smart/immersive/select.ts index 082fc3077..85deeccd7 100644 --- a/addon/components/o-s-s/smart/immersive/select.ts +++ b/addon/components/o-s-s/smart/immersive/select.ts @@ -17,6 +17,7 @@ interface OSSSmartImmersiveSelectComponentSignature extends BaseDropdownArgs { maxItemWidth?: number; addressableAs?: string; multiple?: boolean; + searchEnabled?: boolean; onChange?: (item: string) => void; onSearch?: (keyword: string) => void; onBottomReached?: () => void; diff --git a/addon/components/o-s-s/smart/input.hbs b/addon/components/o-s-s/smart/input.hbs index 6c6ac1fae..14b23dbc7 100644 --- a/addon/components/o-s-s/smart/input.hbs +++ b/addon/components/o-s-s/smart/input.hbs @@ -18,8 +18,8 @@
{{else}} {{#if @loading}} -
- {{@placeholder}} +
+ {{@placeholder}}
{{else}}
`); - assert.dom('.rainbow_text_animated').hasText('Smart...'); + assert.dom('.smart_text_animated').hasText('Smart...'); }); }); From 70de3a9d2a5252e1bd76237d46aaa6b4bd7b0c19 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Wed, 13 Aug 2025 17:14:15 +0200 Subject: [PATCH 52/76] Remove unused css --- app/styles/core/_all.less | 1 - app/styles/core/_smart.less | 3 --- 2 files changed, 4 deletions(-) delete mode 100644 app/styles/core/_smart.less diff --git a/app/styles/core/_all.less b/app/styles/core/_all.less index f1adba3f1..6446ac244 100644 --- a/app/styles/core/_all.less +++ b/app/styles/core/_all.less @@ -3,4 +3,3 @@ @import '_typography'; @import '_flex'; @import '_form'; -@import '_smart'; diff --git a/app/styles/core/_smart.less b/app/styles/core/_smart.less deleted file mode 100644 index 42f1e5f06..000000000 --- a/app/styles/core/_smart.less +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --border-radius-xl: 999px; -} From f6b3ec9121ed5370455561faadeed54925274b1b Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Tue, 19 Aug 2025 14:42:36 +0200 Subject: [PATCH 53/76] Smart infinite-select: Rework style --- app/styles/base/_infinite-select.less | 36 ++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/app/styles/base/_infinite-select.less b/app/styles/base/_infinite-select.less index 7ff6a0456..df17df204 100644 --- a/app/styles/base/_infinite-select.less +++ b/app/styles/base/_infinite-select.less @@ -50,9 +50,20 @@ } .upf-infinite-select--smart { - border: 1px solid var(--color-primary-200); + border: 1px solid var(--color-border-default); + border-radius: var(--border-radius-md); box-shadow: var(--box-shadow-md); - padding: 0; + padding: 6px; + + &.upf-power-select__dropdown  { + overflow: auto; + } + + .upf-infinite-select__items-container { + display: flex; + flex-direction: column; + gap: var(--spacing-px-3); + } .upf-infinite-select--search { margin: var(--spacing-px-12); @@ -64,13 +75,14 @@ align-items: center; color: var(--color-gray-600); padding: var(--spacing-px-3) var(--spacing-px-12); - height: 36px; + min-height: 36px; + max-height: 36px; gap: var(--spacing-px-6); - border-radius: 0; &:has(> .selected), - &:hover { - background-color: var(--color-gray-100); + &:hover, + &:focus { + background-color: var(--color-primary-50); color: var(--color-gray-900); } @@ -81,5 +93,17 @@ .item-wrapper { flex: 1; } + + .upf-checkbox { + min-width: 16px; + width: 16px; + min-height: 16px; + height: 16px; + + &__input:checked + .upf-checkbox__fake-checkbox { + background-color: var(--color-primary-400); + border-color: var(--color-primary-400); + } + } } } From e751a63d32a8503c166b4d1d036eed51d335634e Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Wed, 20 Aug 2025 11:22:14 +0200 Subject: [PATCH 54/76] Fix pr feedback --- app/styles/base/_infinite-select.less | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/styles/base/_infinite-select.less b/app/styles/base/_infinite-select.less index df17df204..8551083d6 100644 --- a/app/styles/base/_infinite-select.less +++ b/app/styles/base/_infinite-select.less @@ -53,7 +53,7 @@ border: 1px solid var(--color-border-default); border-radius: var(--border-radius-md); box-shadow: var(--box-shadow-md); - padding: 6px; + padding: var(--spacing-px-6); &.upf-power-select__dropdown  { overflow: auto; @@ -75,8 +75,8 @@ align-items: center; color: var(--color-gray-600); padding: var(--spacing-px-3) var(--spacing-px-12); - min-height: 36px; - max-height: 36px; + min-height: var(--spacing-px-36); + max-height: var(--spacing-px-36); gap: var(--spacing-px-6); &:has(> .selected), From 9eba31c51021792a2ec4481f99aed2d5948bfbb0 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Mon, 18 Aug 2025 15:33:21 +0200 Subject: [PATCH 55/76] Smart Textarea: Add new component --- addon/components/o-s-s/smart/tag-input.hbs | 4 +- addon/components/o-s-s/smart/tag-input.ts | 4 + addon/components/o-s-s/smart/text-area.hbs | 23 +++ .../o-s-s/smart/text-area.stories.js | 121 +++++++++++++++ addon/components/o-s-s/smart/text-area.ts | 43 ++++++ addon/components/o-s-s/text-area.ts | 14 +- app/components/o-s-s/smart/text-area.js | 1 + app/styles/atoms/smart-textarea.less | 53 +++++++ app/styles/oss-components.less | 1 + tests/dummy/app/controllers/smart.ts | 8 + tests/dummy/app/templates/smart.hbs | 20 +++ .../components/o-s-s/smart/text-area-test.ts | 144 ++++++++++++++++++ 12 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 addon/components/o-s-s/smart/text-area.hbs create mode 100644 addon/components/o-s-s/smart/text-area.stories.js create mode 100644 addon/components/o-s-s/smart/text-area.ts create mode 100644 app/components/o-s-s/smart/text-area.js create mode 100644 app/styles/atoms/smart-textarea.less create mode 100644 tests/integration/components/o-s-s/smart/text-area-test.ts diff --git a/addon/components/o-s-s/smart/tag-input.hbs b/addon/components/o-s-s/smart/tag-input.hbs index 60dc39551..2de81268d 100644 --- a/addon/components/o-s-s/smart/tag-input.hbs +++ b/addon/components/o-s-s/smart/tag-input.hbs @@ -10,9 +10,7 @@
{{else}}
- - {{if (or this.inputValue this.isInputFocused) this.inputValue this.placeholder}} - + {{this.hiddenSpanValue}} { return this.args.placeholder ?? this.intl.t('oss-components.smart.tag_input.placeholder'); } + get hiddenSpanValue(): string { + return this.inputValue || this.isInputFocused ? this.inputValue : this.placeholder; + } + @action registerElement(element: HTMLElement): void { this.element = element; diff --git a/addon/components/o-s-s/smart/text-area.hbs b/addon/components/o-s-s/smart/text-area.hbs new file mode 100644 index 000000000..c1bf990b9 --- /dev/null +++ b/addon/components/o-s-s/smart/text-area.hbs @@ -0,0 +1,23 @@ +
+
+ {{#if @loading}} +
+
{{@placeholder}}
+
+ {{/if}} +