diff --git a/addon/components/o-s-s/button.ts b/addon/components/o-s-s/button.ts index f6cde2e66..f01400b1c 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; @@ -87,15 +87,17 @@ interface ButtonArgs { }; } -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/currency-input.ts b/addon/components/o-s-s/currency-input.ts index edbab6410..03bcfcb26 100644 --- a/addon/components/o-s-s/currency-input.ts +++ b/addon/components/o-s-s/currency-input.ts @@ -10,7 +10,7 @@ export type Currency = { symbol: string; }; -interface OSSCurrencyInputArgs { +export interface OSSCurrencyInputArgs { currency: string; value: number; onChange(currency: string, value: number): void; @@ -70,16 +70,18 @@ const AUTHORIZED_INPUTS = [ 'ArrowDown' ]; -export default class OSSCurrencyInput extends Component { +export default class OSSCurrencyInput extends Component { private currencies = this.args.allowedCurrencies ?? PLATFORM_CURRENCIES; @tracked currencySelectorShown: boolean = false; @tracked filteredCurrencies: Currency[] = this.currencies; @tracked localValue: number = this.args.value; - constructor(owner: unknown, args: OSSCurrencyInputArgs) { + constructor(owner: unknown, args: OSSCurrencyInputArgs, preventDefaultAssertions?: boolean) { super(owner, args); + if (preventDefaultAssertions) return; + if (!this.args.value && !this.args.placeholder) { this.localValue = 0; } diff --git a/addon/components/o-s-s/infinite-select.hbs b/addon/components/o-s-s/infinite-select.hbs index 61490c6c9..32232353c 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="upf-infinite-select--search" {{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/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/number-input.ts b/addon/components/o-s-s/number-input.ts index 9bce0d59c..f3fde026b 100644 --- a/addon/components/o-s-s/number-input.ts +++ b/addon/components/o-s-s/number-input.ts @@ -2,7 +2,7 @@ import { action } from '@ember/object'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -interface OSSNumberInputArgs { +export interface OSSNumberInputArgs { value?: number; min?: number; max?: number; @@ -21,7 +21,7 @@ const DECREASE_VALUE_KEYS = ['ArrowDown', 'ArrowLeft']; const BASE_INPUT_PIXEL_WIDTH = 40; const CHAR_PIXEL_WIDTH = 7; -export default class OSSNumberInput extends Component { +export default class OSSNumberInput extends Component { @tracked localValue: number = this.args.value || DEFAULT_VALUE; @tracked reachedTooltip: string | null = null; @tracked inputElement: HTMLElement | null = null; 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/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/button.hbs b/addon/components/o-s-s/smart/button.hbs new file mode 100644 index 000000000..62d1b97a4 --- /dev/null +++ b/addon/components/o-s-s/smart/button.hbs @@ -0,0 +1,26 @@ +{{! 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..8c3aa3cae --- /dev/null +++ b/addon/components/o-s-s/smart/button.stories.js @@ -0,0 +1,143 @@ +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' + } + }, + 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, + loadingOptions: undefined, + iconUrl: undefined, + disabled: false +}; + +const Template = (args) => ({ + template: hbs` + + `, + context: args +}); + +export const Default = Template.bind({}); +Default.args = defaultArgs; + +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..d7e18e9b0 --- /dev/null +++ b/addon/components/o-s-s/smart/button.ts @@ -0,0 +1,53 @@ +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 + ); + } + + 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/feedback.hbs b/addon/components/o-s-s/smart/feedback.hbs new file mode 100644 index 000000000..aba1704e3 --- /dev/null +++ b/addon/components/o-s-s/smart/feedback.hbs @@ -0,0 +1,23 @@ + \ 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 new file mode 100644 index 000000000..2b2671a68 --- /dev/null +++ b/addon/components/o-s-s/smart/feedback.ts @@ -0,0 +1,15 @@ +import Component from '@glimmer/component'; + +interface OSSSmartFeedbackArgs { + loading: boolean; + contentString?: string; + contentArray?: string[]; +} + +export default class OSSSmartFeedback extends Component { + get currentStateClass(): string | null { + if (this.args.loading) return 'oss-smart__feedback__loading'; + if (this.args.contentString || this.args.contentArray) return 'oss-smart__generated'; + return null; + } +} diff --git a/addon/components/o-s-s/smart/immersive/currency-input.hbs b/addon/components/o-s-s/smart/immersive/currency-input.hbs new file mode 100644 index 000000000..6d994be85 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/currency-input.hbs @@ -0,0 +1,72 @@ +
+
+
+
+
+ {{this.selectedCurrencySymbol}} +
+
+ + {{#if this.allowCurrencyUpdate}} + + {{/if}} +
+ {{#if @loading}} +
+ {{@placeholder}} +
+ {{else}} +
+ {{or @value @placeholder}} + +
+ {{/if}} +
+ {{#if this.currencySelectorShown}} + {{#in-element this.portalTarget insertBefore=null}} + + <:option as |currency|> +
+ {{currency.symbol}} + {{currency.code}} + {{#if (eq this.selectedCurrency currency)}} + + {{/if}} +
+ +
+ {{/in-element}} + {{/if}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/immersive/currency-input.stories.js b/addon/components/o-s-s/smart/immersive/currency-input.stories.js new file mode 100644 index 000000000..bd26496ba --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/currency-input.stories.js @@ -0,0 +1,98 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Components/OSS::Smart::Immersive::CurrencyInput', + component: 'currency-input', + argTypes: { + currency: { + description: 'The currency applied to the component (EUR, USD, etc.)', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'USD' } + }, + control: { type: 'text' } + }, + value: { + description: 'The value applied to the input', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: 0 } + }, + control: { type: 'number' } + }, + placeholder: { + description: 'Placeholder for the number input when no value is passed', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: null } + }, + control: { type: 'text' } + }, + onChange: { + type: { required: true }, + description: 'A callback that sends the modifications of the value & the currency back to the parent component', + table: { + category: 'Actions', + type: { summary: 'onChange(currency: string, value: number): void' } + } + }, + allowedCurrencies: { + description: 'Allows passing a custom set of selectable currencies to the component.', + table: { + type: { + summary: '{ code: string, symbol: string }[]' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'array' } + } + }, + hasError: { + description: 'Displays an error border around the input.', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' } + }, + control: { + type: 'boolean' + } + }, + parameters: { + docs: { + description: { + component: 'A smart and immersive currency selector & input, used to set prices.' + }, + iframeHeight: 200 + } + } +}; + +const defaultArgs = { + value: 42, + currency: 'USD', + onChange: action('onChange'), + hasError: false, + allowedCurrencies: undefined, + placeholder: undefined +}; + +const Template = (args) => ({ + template: hbs` +
+ +
+ `, + context: args +}); + +export const BasicUsage = Template.bind({}); +BasicUsage.args = defaultArgs; diff --git a/addon/components/o-s-s/smart/immersive/currency-input.ts b/addon/components/o-s-s/smart/immersive/currency-input.ts new file mode 100644 index 000000000..025557efe --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/currency-input.ts @@ -0,0 +1,92 @@ +import { assert } from '@ember/debug'; +import OSSCurrencyInput, { type OSSCurrencyInputArgs } from '../../currency-input'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { runSmartGradientAnimation } from '@upfluence/oss-components/utils/run-smart-gradient-animation'; +import { isEmpty } from '@ember/utils'; +import { guidFor } from '@ember/object/internals'; +import attachDropdown from '@upfluence/oss-components/utils/attach-dropdown'; +import { scheduleOnce } from '@ember/runloop'; +import { isTesting } from '@embroider/macros'; + +interface OSSSmartImmersiveCurrencyInputArgs extends OSSCurrencyInputArgs { + loading: boolean; + hasError?: boolean; + addressableAs?: string; +} + +export default class OSSSmartImmersiveCurrencyInput extends OSSCurrencyInput { + declare portalTarget: HTMLElement; + portalId: string = guidFor(this); + + @tracked declare element: HTMLElement; + + constructor(owner: unknown, args: OSSCurrencyInputArgs) { + super(owner, args, true); + + assert( + '[component][OSS::Smart::Immersive::CurrencyInput] The parameter @onChange of type function is mandatory', + typeof this.args.onChange === 'function' + ); + } + + get placeholder(): string { + return this.args.placeholder ?? ''; + } + + get computedClasses(): string { + const classes = ['smart-immersive-currency-input-container']; + + if (this.args.value && !this.args.loading) { + classes.push('smart-immersive-currency-input-container--filled'); + } + if (this.args.hasError) { + classes.push('smart-immersive-currency-input-container--errored'); + } + return classes.join(' '); + } + + @action + onChange(currency: string, value: number): void { + this.args.onChange(currency, value); + } + + @action + registerElement(element: HTMLElement): void { + this.portalTarget = isTesting() ? element : document.body; + this.element = element; + } + + @action + runAnimationOnLoadEnd(): void { + if (this.element && this.args.loading === false && !isEmpty(this.args.value)) { + runSmartGradientAnimation(this.element); + } + } + + @action + toggleCurrencySelector(e: any): void { + super.toggleCurrencySelector(e); + + scheduleOnce('afterRender', this, this.setupDropdownAutoplacement); + } + + get dropdownAddressableClass(): string { + return this.args.addressableAs ? `${this.args.addressableAs}__dropdown` : ''; + } + + cleanupDrodpownAutoplacement?: () => void; + + private setupDropdownAutoplacement(): void { + const referenceTarget = this.element.querySelector('.currency-selector'); + const floatingTarget = document.querySelector(`#${this.portalId}`); + + if (referenceTarget && floatingTarget) { + this.cleanupDrodpownAutoplacement = attachDropdown( + referenceTarget as HTMLElement, + floatingTarget as HTMLElement, + { maxHeight: 200, maxWidth: 320, placement: 'bottom-start', fallbackPlacements: ['top-start'] } + ); + } + } +} 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..ef3839a64 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/input.hbs @@ -0,0 +1,40 @@ + + <:input> +
+ {{#if (has-block "prefix")}} +
+ {{yield to="prefix"}} +
+ {{/if}} + {{#if @loading}} +
+ {{@placeholder}} +
+ {{else}} +
+ {{or @value @placeholder}} + +
+ {{/if}} + {{#if (has-block "suffix")}} +
+ {{yield to="suffix"}} +
+ {{/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..a0f605cb4 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/input.stories.js @@ -0,0 +1,136 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Components/OSS::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; + +const PrefixUsageTemplate = (args) => ({ + template: hbs` + + <:prefix> + + + + `, + context: args +}); + +export const PrefixUsage = PrefixUsageTemplate.bind({}); +PrefixUsage.args = defaultArgs; + +const SuffixUsageTemplate = (args) => ({ + template: hbs` + + <:suffix> + + + + `, + context: args +}); + +export const SuffixUsage = SuffixUsageTemplate.bind({}); +SuffixUsage.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..3da16099c --- /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; + + 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(_: string, event: Event): void { + if (this.args.onChange) { + const target = event.target as HTMLInputElement; + this.args.onChange(target.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/addon/components/o-s-s/smart/immersive/logo.hbs b/addon/components/o-s-s/smart/immersive/logo.hbs new file mode 100644 index 000000000..d407b9025 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/logo.hbs @@ -0,0 +1,19 @@ +
+ {{#if @editable}} +
+ +
+ {{/if}} + {{#if (eq this.logoMode "icon")}} +
+ +
+ {{else}} + Smart branding + {{/if}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/immersive/logo.stories.js b/addon/components/o-s-s/smart/immersive/logo.stories.js new file mode 100644 index 000000000..b962ef351 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/logo.stories.js @@ -0,0 +1,150 @@ +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/OSS::Smart::Immersive::Logo', + component: 'oss-smart-immersive-logo', + argTypes: { + icon: { + name: 'Icon', + description: 'icon name and color for icon mode (e.g., "rabbit:orange")', + table: { disable: false }, + control: { type: 'text' } + }, + iconName: { + name: 'Icon Name', + description: 'Name of the icon to display, concatenated with iconColor to form "iconName:iconColor"', + control: { + type: 'select', + options: LOGO_ICONS + } + }, + iconColor: { + name: 'Icon Color', + description: 'Color of the icon to display, concatenated with iconName to form "iconName:iconColor"', + control: { + type: 'select', + options: LOGO_COLORS + } + }, + + url: { + description: 'URL of the image for image mode', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + editable: { + description: 'Whether the logo is editable and shows an overlay', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + loading: { + description: 'Whether the logo is in a loading state', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + size: { + description: 'Allow to define the size of the container', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'md' } + }, + control: { + type: 'select', + options: ['sm', 'md', 'lg'] + } + }, + hasError: { + description: 'Display an error border around the component.', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + onEdit: { + description: 'Action triggered when clicking the edit overlay', + table: { + category: 'Actions', + type: { summary: '() => void' } + } + } + }, + parameters: { + docs: { + description: { + component: 'Smart immersive logo component which supports icon or image display and editable overlay.' + } + } + } +}; + +const Template = ({ iconName, iconColor, ...rest }) => { + const icon = iconName && iconColor ? `${iconName}:${iconColor}` : undefined; + + return { + template: hbs` +
+ +
+ `, + context: { + ...rest, + icon + } + }; +}; + +export const Default = Template.bind({}); +Default.args = { + iconName: 'rabbit', + iconColor: 'orange', + url: undefined, + editable: false, + loading: false, + size: 'md', + hasError: false, + onEdit: action('onEdit') +}; + +export const Editable = Template.bind({}); +Editable.args = { + ...Default.args, + editable: true +}; + +export const ImageMode = Template.bind({}); +ImageMode.args = { + ...Default.args, + url: 'https://example.com/logo.png' +}; + +export const LoadingState = Template.bind({}); +LoadingState.args = { + ...Default.args, + loading: true +}; + +export const OversizeState = Template.bind({}); +OversizeState.args = { + ...Default.args, + oversize: true +}; diff --git a/addon/components/o-s-s/smart/immersive/logo.ts b/addon/components/o-s-s/smart/immersive/logo.ts new file mode 100644 index 000000000..235f3b90e --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/logo.ts @@ -0,0 +1,93 @@ +import { assert } from '@ember/debug'; +import { action } from '@ember/object'; +import { isBlank } from '@ember/utils'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { runSmartGradientAnimation } from '@upfluence/oss-components/utils/run-smart-gradient-animation'; + +const FALLBACK_SVG_URL = '/assets/images/picture-frame.svg'; +type size = 'sm' | 'md' | 'lg'; + +interface OSSSmartImmersiveLogoArgs { + icon?: string; + url?: string; + editable: boolean; + loading?: boolean; + hasError?: boolean; + size?: size; + onEdit(): void; +} + +const BASE_COMPONENT_CLASS = 'oss-smart__immersive-icon-container'; + +export default class OSSSmartImmersiveLogoComponent extends Component { + @tracked declare element: HTMLElement; + fallbackSvgUrl = FALLBACK_SVG_URL; + + constructor(owner: unknown, args: OSSSmartImmersiveLogoArgs) { + super(owner, args); + + if (!args.url && args.icon) { + const parts = args.icon.split(':'); + if (parts) { + assert( + `Invalid icon format "${args.icon}". Expected format is "iconName:colorName".`, + parts.length === 2 && + typeof parts[0] === 'string' && + typeof parts[1] === 'string' && + parts[0].trim().length > 0 && + parts[1].trim().length > 0 + ); + } + } + } + + get size(): size { + return this.args.size ?? 'md'; + } + + get computedClass(): string { + const classes = [BASE_COMPONENT_CLASS]; + + if (this.args.loading) { + classes.push(`${BASE_COMPONENT_CLASS}--generating`); + } + if (this.args.hasError) { + classes.push(`${BASE_COMPONENT_CLASS}--errored`); + } + if (this.size) { + classes.push(`${BASE_COMPONENT_CLASS}--size-${this.size}`); + } + + return classes.join(' '); + } + + get logoColor(): string | null { + return this.args.icon ? `smart-logo-icon-color_${this.args.icon.split(':')[1]}` : null; + } + + get logoIcon(): string | null { + return this.args.icon?.split(':')[0] ?? null; + } + + get logoMode(): 'icon' | 'image' { + return this.args.url || !this.args.icon ? 'image' : 'icon'; + } + + @action + handleLoadingUpdate(): void { + if (this.args.loading === false && !isBlank(this.args.url ?? this.args.icon)) { + runSmartGradientAnimation(this.element); + } + } + + @action + registerElement(element: HTMLElement): void { + this.element = element; + } + + @action + onError(event: Event): void { + (event.target).src = FALLBACK_SVG_URL; + } +} 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..8eaf4082e --- /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 (and @multiple (not @hideCheckboxes))}} + + {{/if}} + {{yield item to="option-item"}} + {{#if (and (not @multiple) (this.isSelected 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..22707f3dd --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/select.stories.js @@ -0,0 +1,207 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Components/OSS::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' } + }, + values: { + name: 'Values', + description: 'Selected values for multi-select mode.', + table: { + type: { + summary: '>' + }, + defaultValue: { summary: '[]' } + }, + control: { type: 'array' } + }, + loading: { + name: 'Loading', + type: { name: 'boolean' }, + description: 'Enable the loading state.', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + multiple: { + name: 'Multiple', + type: { name: 'boolean' }, + description: 'Allow multiple selections.', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + hideCheckboxes: { + name: 'Hide Checkboxes', + type: { name: 'boolean' }, + description: 'Hide checkboxes in multiple selection mode.', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: false } + }, + control: { type: 'boolean' } + }, + searchEnabled: { + name: 'Search Enabled', + type: { name: 'boolean' }, + description: 'Enable the search functionality.', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: true } + }, + control: { type: 'boolean' } + }, + items: { + name: '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.value}} + <:option-item as |item|> +
+ {{item.label}} +
+ +
+
+ `, + context: { + ...rest, + icon + } + }; +}; + +export const Select = TemplateSingle.bind({}); + +Select.args = { + placeholder: 'Placeholder', + values: [{ value: 'step 1' }], + loading: false, + multiple: false, + hideCheckboxes: false, + searchEnabled: 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') +}; + +export const Multiple = Template.bind({}); + +Multiple.args = { + placeholder: 'Placeholder', + values: [{ value: 'step 1' }, { value: 'step 2' }], + loading: false, + multiple: true, + hideCheckboxes: false, + searchEnabled: 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..478d3c083 --- /dev/null +++ b/addon/components/o-s-s/smart/immersive/select.ts @@ -0,0 +1,149 @@ +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 { isBlank } 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'; + +export type SmartImmersiveSelectItem = { + value: any; + label: string; +}; + +interface OSSSmartImmersiveSelectComponentSignature extends BaseDropdownArgs { + values?: SmartImmersiveSelectItem[]; + items: SmartImmersiveSelectItem[]; + placeholder?: string; + loading?: boolean; + hasError?: boolean; + displayedItems?: number; + maxItemWidth?: number; + addressableAs?: string; + multiple?: boolean; + searchEnabled?: boolean; + hideCheckboxes?: boolean; + onChange?: (item: string) => void; + onSearch?: (keyword: string) => void; + onBottomReached?: () => void; +} + +export default class OSSSmartImmersiveSelectComponent extends BaseDropdown { + cleanupDrodpownAutoplacement?: () => void; + + isSelected = helper((_, { value }: { value: any }): boolean => { + return this.args.values?.some((item) => item.value === value) || false; + }); + + get hasValue(): boolean { + return !isBlank(this.args.values); + } + + get computedClasses(): string { + const classes = ['smart-immersive-select-container']; + if (this.hasValue) { + 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(): SmartImmersiveSelectItem[] { + let values: SmartImmersiveSelectItem[] = []; + values = [ + ...(this.args.values?.filter((el) => { + return !isBlank(el) && el !== undefined; + }) ?? []) + ]; + + if (this.displayedItems > 0 && values.length > this.displayedItems) { + values = values.slice(0, this.displayedItems); + values.push({ + value: `+${(this.args.values?.length ?? 0) - this.displayedItems}`, + label: `+${(this.args.values?.length ?? 0) - this.displayedItems}` + }); + } + + return values; + } + + @action + runAnimationOnLoadEnd(): void { + if (this.container && this.args.loading === false && this.hasValue) { + runSmartGradientAnimation(this.container); + } + } + + @action + onSelect(selection: any): void { + this.args.onChange?.(selection); + if (!this.args.multiple) { + this.closeDropdown(); + } + } + + @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::Smart::Immersive::Select] You must pass selected-item named block`, hasSelectedItem); + assert(`[component][OSS::Smart::Immersive::Select] 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); + } + + @action + onClickOutside(element: HTMLElement, event: PointerEvent): void { + this.args.onSearch?.(''); + super.onClickOutside(element, event); + } + + 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, placement: 'bottom-start', fallbackPlacements: ['top-start'] } + ); + } + } +} 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..14b23dbc7 --- /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..8ab1389f9 --- /dev/null +++ b/addon/components/o-s-s/smart/input.stories.js @@ -0,0 +1,129 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Components/OSS::Smart::Input', + component: 'button', + argTypes: { + 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: { + type: { required: true }, + control: 'boolean', + description: 'Flag to display loading state', + defaultValue: false, + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false } + } + } + } +}; + +const Template = (args) => ({ + template: hbs` + + `, + context: args +}); + +const defaultArgs = { + value: 'John', + disabled: false, + type: undefined, + placeholder: 'this is the placeholder', + errorMessage: undefined, + onChange: action('onChange'), + loading: false +}; + +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..866214d4d --- /dev/null +++ b/addon/components/o-s-s/smart/input.ts @@ -0,0 +1,28 @@ +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; + + @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/addon/components/o-s-s/smart/number-input.hbs b/addon/components/o-s-s/smart/number-input.hbs new file mode 100644 index 000000000..04a5bce93 --- /dev/null +++ b/addon/components/o-s-s/smart/number-input.hbs @@ -0,0 +1,46 @@ +
+
+ + + {{! template-lint-disable no-triple-curlies}} + + + +
+ + {{#if @errorMessage}} + {{@errorMessage}} + {{else if @feedbackMessage}} + + {{#unless (eq @feedbackMessage.type "error")}} + + {{/unless}} + {{@feedbackMessage.value}} + + {{/if}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/smart/number-input.stories.js b/addon/components/o-s-s/smart/number-input.stories.js new file mode 100644 index 000000000..757190728 --- /dev/null +++ b/addon/components/o-s-s/smart/number-input.stories.js @@ -0,0 +1,159 @@ +import { hbs } from 'ember-cli-htmlbars'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Components/OSS::Smart::NumberInput', + component: 'smart-number-input', + argTypes: { + value: { + description: '[OPTIONAL] The current value of the input', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'number' } + }, + min: { + description: '[OPTIONAL] The minimum value the number can be', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'number' } + }, + max: { + description: '[OPTIONAL] The maximum value the number can be', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'number' } + }, + step: { + description: '[OPTIONAL] The increase & decrease value of each button press. Defaults to 1', + table: { + type: { + summary: 'number' + }, + defaultValue: { summary: '1' } + }, + control: { type: 'number' } + }, + loading: { + description: '[OPTIONAL] Shows loading state with animated placeholder', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: 'false' } + }, + control: { type: 'boolean' } + }, + placeholder: { + description: '[OPTIONAL] Placeholder text shown during loading state', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'text' } + }, + hasError: { + description: '[OPTIONAL] Applies error styling to the component', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: 'false' } + }, + control: { type: 'boolean' } + }, + disabled: { + description: '[OPTIONAL] Disables all interactions with the component', + table: { + type: { + summary: 'boolean' + }, + defaultValue: { summary: 'false' } + }, + control: { type: 'boolean' } + }, + onChange: { + description: '[OPTIONAL] Callback triggered when the value changes', + table: { + category: 'Actions', + type: { + summary: 'onChange(value: number): void' + } + } + } + }, + parameters: { + docs: { + description: { + component: + 'A smart number input component with loading states, error handling, and gradient animations. Features decrease & increase buttons with keyboard navigation support.' + } + } + } +}; + +const DefaultTemplate = (args) => ({ + template: hbs` + + `, + context: args +}); + +export const Default = DefaultTemplate.bind({}); +Default.args = { + value: 10, + onChange: action('onChange') +}; + +export const Loading = DefaultTemplate.bind({}); +Loading.args = { + loading: true, + placeholder: 'Loading...', + onChange: action('onChange') +}; + +export const Filled = DefaultTemplate.bind({}); +Filled.args = { + value: 100, + onChange: action('onChange') +}; + +export const Error = DefaultTemplate.bind({}); +Error.args = { + value: 50, + hasError: true, + errorMessage: 'Invalid value', + onChange: action('onChange') +}; + +export const Disabled = DefaultTemplate.bind({}); +Disabled.args = { + value: 25, + disabled: true, + onChange: action('onChange') +}; diff --git a/addon/components/o-s-s/smart/number-input.ts b/addon/components/o-s-s/smart/number-input.ts new file mode 100644 index 000000000..43e124d08 --- /dev/null +++ b/addon/components/o-s-s/smart/number-input.ts @@ -0,0 +1,49 @@ +import OSSNumberInput, { type OSSNumberInputArgs } from '../number-input'; +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'; + +export interface OSSSmartNumberInputArgs extends OSSNumberInputArgs { + loading?: boolean; + hasError?: boolean; + errorMessage?: string; + feedbackMessage?: { + type: 'error' | 'warning'; + value: string; + }; +} + +export default class OSSSmartNumberInput extends OSSNumberInput { + declare args: OSSSmartNumberInputArgs; + @tracked declare element: HTMLElement; + + get computedClasses(): string { + const classes = ['smart-number-input']; + + if (this.args.loading) { + classes.push('smart-number-input--loading'); + } else if (this.args.value !== undefined && this.args.value !== null) { + classes.push('smart-number-input--filled'); + } + if (this.args.hasError || this.args.errorMessage) { + classes.push('smart-number-input--errored'); + } + if (this.args.feedbackMessage) { + classes.push(`smart-number-input--${this.args.feedbackMessage.type}`); + } + return classes.join(' '); + } + + @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/addon/components/o-s-s/smart/pill.ts b/addon/components/o-s-s/smart/pill.ts index 99fd696a7..6b50785c5 100644 --- a/addon/components/o-s-s/smart/pill.ts +++ b/addon/components/o-s-s/smart/pill.ts @@ -31,7 +31,7 @@ export default class OSSSmartPill extends OSSPill { @action runAnimationOnLoadEnd(): void { - if (this.element && this.args.loading === false) { + if (this.element && this.args.loading === false && this.args.selected) { runSmartGradientAnimation(this.element); } } 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/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..d9de0f55b --- /dev/null +++ b/addon/components/o-s-s/smart/tag-input.hbs @@ -0,0 +1,34 @@ +
+
+ {{#if @loading}} +
+ {{or this.inputValue this.placeholder}} +
+ {{else}} +
+ {{this.hiddenSpanValue}} + +
+
+ {{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..2895b6694 --- /dev/null +++ b/addon/components/o-s-s/smart/tag-input.stories.js @@ -0,0 +1,79 @@ +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' } + } + }, + placeholder: { + description: 'The placeholder to show when the input is empty', + table: { + type: { + summary: 'string' + }, + defaultValue: { summary: undefined } + }, + control: { type: 'text' } + } + }, + parameters: { + docs: { + description: { + component: 'A smart tag input component for entering keywords, @mentions, and #hashtags.' + } + } + } +}; + +const defaultArgs = { + value: 'Keyword', + loading: false, + hasError: false, + placeholder: 'Type a keyword, mention, or hashtag' +}; + +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..c95003f63 --- /dev/null +++ b/addon/components/o-s-s/smart/tag-input.ts @@ -0,0 +1,105 @@ +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; + placeholder?: string; +} + +export default class OSSSmartTagInput extends Component { + @service declare intl: IntlService; + + @tracked inputValue: string = this.args.value || ''; + @tracked isInputFocused: boolean = false; + @tracked lastFocusInTime: number = 0; + 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.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; + } + + @action + onClickQueryBuilder(): void { + this.focusKeywordInput(); + } + + @action + onFocusin(): void { + this.lastFocusInTime = Date.now(); + this.isInputFocused = true; + } + + @action + onKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === 'Tab') { + this.formatAndNotify(); + } + } + + @action + onClickOutside(_: any, event: Event): void { + if (Date.now() - this.lastFocusInTime < 150) { + return; + } + if (!this.isInputFocused) return; + this.isInputFocused = false; + if (isBlank(this.inputValue)) return; + event.stopImmediatePropagation(); + this.formatAndNotify(); + } + + private formatAndNotify(): void { + if (this.inputValue.trim().length > 0) { + let type: TagType = 'keyword'; + if (this.inputValue.startsWith('@')) { + type = 'mention'; + } else if (this.inputValue.startsWith('#')) { + type = 'hashtag'; + } + const 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..5defd8522 --- /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..3f093854e --- /dev/null +++ b/addon/components/o-s-s/smart/tag.ts @@ -0,0 +1,63 @@ +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'; +export type TagTypeDefinition = { icon?: string; color: string }; + +const TypeDefinition: Record = { + hashtag: { icon: 'fa-hashtag', color: 'cyan' }, + mention: { icon: 'fa-at', color: 'violet' }, + keyword: { icon: 'x', color: 'yellow' } +}; + +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(): string { + return TypeDefinition[this.args.type ?? 'keyword'].color; + } + + get typeIcon(): string | undefined { + return TypeDefinition[this.args.type ?? 'keyword'].icon; + } + + 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/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..7133aacdd --- /dev/null +++ b/addon/components/o-s-s/smart/text-area.hbs @@ -0,0 +1,23 @@ +
+
+ {{#if @loading}} +
+
{{or @value @placeholder}}
+
+ {{/if}} +