diff --git a/packages/assets/css/src/scss/_components/_dropdown/_dropdown.scss b/packages/assets/css/src/scss/_components/_dropdown/_dropdown.scss index 4ed6cbec4..06f868baf 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_dropdown.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_dropdown.scss @@ -8,6 +8,7 @@ @include sui-dropdown--element.build-cta(dropdown); @include sui-dropdown--element.build-menu-group(dropdown); @include sui-dropdown--element.build-menu-item(dropdown); +@include sui-dropdown--element.build-menu-items(dropdown); @include sui-dropdown--modifier.modify-size(dropdown); @include sui-dropdown--modifier.modify-placement(dropdown); @@ -16,3 +17,6 @@ @include sui-dropdown--state.edit-hover(dropdown); @include sui-dropdown--state.edit-disabled(dropdown); @include sui-dropdown--state.edit-variation(dropdown); +@include sui-dropdown--state.edit-fluid(dropdown); +@include sui-dropdown--state.edit-current(dropdown); +@include sui-dropdown--state.edit-type(dropdown); diff --git a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-group.scss b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-group.scss index 5a25b2bdc..e675fdd53 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-group.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-group.scss @@ -26,7 +26,7 @@ line-height: $font-height-xs; font-weight: $font-weight-md; letter-spacing: $font-spacing-xxl; - padding: $spacing-md $menu-spacing-horizontal; + padding: $spacing-md $spacing-lg; text-transform: uppercase; } diff --git a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-item.scss b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-item.scss index 16133b4f2..e73b79668 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-item.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-item.scss @@ -18,12 +18,48 @@ align-items: center; cursor: pointer; display: flex; - font-weight: $font-weight-md; + font: $font-weight-default #{$font-size-xs}/#{$font-height-xs} + $font-family-default; gap: $spacing-md; list-style: none; + line-height: $font-height-sm; margin: 0; - padding: $spacing-md $menu-spacing-horizontal; + padding: $spacing-sm $spacing-lg; text-decoration: none; + + @include modifier(wrapper) { + display: flex; + gap: $spacing-sm; + flex: 1; + align-items: flex-start; + + .sui-#{$block}__menu-item-icon { + margin-top: $spacing-xs; + } + } + + @include modifier(title) { + font-weight: $font-weight-md; + } + + @include modifier(loading) { + align-items: center; + align-self: stretch; + background: $color-extended-neutral-95; + border-radius: $border-radius-md; + display: flex; + font: $font-weight-default #{$font-size-xs}/#{$font-height-xs} + $font-family-default; + gap: $spacing-md; + padding: $spacing-default; + margin: 0 $spacing-md $spacing-md; + } + + @include modifier(loading-alt) { + background: transparent; + padding: $spacing-md $spacing-lg; + margin: 0; + } } @include element(menu) { diff --git a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-items.scss b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-items.scss new file mode 100644 index 000000000..c703ecd1b --- /dev/null +++ b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu-items.scss @@ -0,0 +1,35 @@ +@use "sass:map"; + +@use "../../../_utils/utils" as *; + +/// Build menu items +/// +/// @type block +/// @author WPMU DEV +/// +/// @param {String} $block - Main block name +@mixin build-menu-items($block) { + // DIR: Left to right. + // THEME: None. + @include sui-class($rtl: false, $theme: null) { + // Block wrapper. + @include block($block) { + @include element(menu-items) { + max-height: $dropdown-size-height-md; + overflow-y: auto; + margin: 0; + } + } + } + + // DIR: Left to right. + // THEME: Dark. + @include sui-class($rtl: false, $theme: dark) { + // Block wrapper. + @include block($block) { + @include element(menu-items) { + background: $color-extended-neutral-100; + } + } + } +} diff --git a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu.scss b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu.scss index 43fe59147..8c5d3c0eb 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-menu.scss @@ -13,6 +13,10 @@ margin: 0; padding: 0; } + + @include element(menu-nav-search) { + padding: 0px 12px 8px 12px; + } } } } diff --git a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-popover.scss b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-popover.scss index 6888f9e80..b28d55054 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-popover.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_elements/_dropdown-popover.scss @@ -12,8 +12,7 @@ @include sui-class($rtl: false, $theme: null) { @include block($block-name) { @include element(popover) { - width: $dropdown-size-width-md; - overflow-y: auto; + min-width: 120px; z-index: 100; position: absolute; top: 100%; @@ -27,10 +26,22 @@ $color-shadow-default, 0 $shadow-offset-2xs $shadow-offset-xl $color-shadow-light, 0 $shadow-offset-xs $shadow-offset-sm $color-shadow-dark; - max-width: $dropdown-size-width-sm; - @include modifier(fixed-height) { - max-height: $dropdown-size-height-md; + @include modifier(select-variable) { + .sui-#{$block-name}__menu-item { + line-height: $font-height-xs; + border-bottom: $border-width-sm solid $color-extended-neutral-80; + + &:last-child { + border-bottom: 0; + } + } + } + + @include modifier(select-checkbox, select-variable) { + .sui-#{$block-name}__menu-item { + padding: $spacing-md $spacing-lg; + } } } } diff --git a/packages/assets/css/src/scss/_components/_dropdown/_elements/_elements.scss b/packages/assets/css/src/scss/_components/_dropdown/_elements/_elements.scss index 81c62298c..217462de5 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_elements/_elements.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_elements/_elements.scss @@ -3,4 +3,5 @@ @forward "./dropdown-popover"; @forward "./dropdown-menu"; @forward "./dropdown-menu-group"; +@forward "./dropdown-menu-items"; @forward "./dropdown-menu-item"; diff --git a/packages/assets/css/src/scss/_components/_dropdown/_modifiers/_dropdown-placement.scss b/packages/assets/css/src/scss/_components/_dropdown/_modifiers/_dropdown-placement.scss index fd46a5ac2..e081ade1b 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_modifiers/_dropdown-placement.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_modifiers/_dropdown-placement.scss @@ -14,6 +14,11 @@ left: auto; right: 0; } + + @include modifier(placement-top) { + top: auto; + bottom: 100%; + } } } } diff --git a/packages/assets/css/src/scss/_components/_dropdown/_states/_dropdown-open.scss b/packages/assets/css/src/scss/_components/_dropdown/_states/_dropdown-open.scss index b434852a9..5294634ce 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_states/_dropdown-open.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_states/_dropdown-open.scss @@ -10,7 +10,7 @@ @include sui-class($rtl: false, $theme: null) { @include block($block-name) { @include modifier(open) { - @include modifies-element(popover) { + & > .sui-#{$block-name}__popover { display: block; } } diff --git a/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-current.scss b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-current.scss new file mode 100644 index 000000000..b16cb68a7 --- /dev/null +++ b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-current.scss @@ -0,0 +1,33 @@ +@use "../../../_utils/utils" as *; + +/// Edit Current State +/// +/// @type state +/// @author WPMU DEV +/// +/// @param {String} $block - Main block name +@mixin edit-current($block) { + // DIR: Left to right. + // THEME: None. + @include sui-class($rtl: false, $theme: null) { + @include block($block) { + @include element(menu) { + .sui-#{$block}__menu-item--current { + color: #0059ff; + } + } + } + } + + // DIR: Left to right. + // THEME: Dark. + @include sui-class($rtl: false, $theme: dark) { + @include block($block) { + @include element(menu) { + .sui-#{$block}__menu-item--current { + color: #0059ff; + } + } + } + } +} diff --git a/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-disabled.scss b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-disabled.scss index 357169871..5b68bf9a3 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-disabled.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-disabled.scss @@ -13,6 +13,7 @@ @include block($block) { @include element(menu-item) { @include modifier(disabled) { + color: $color-extended-neutral-10; opacity: 0.5; pointer-events: none; user-select: none; diff --git a/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-fluid.scss b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-fluid.scss new file mode 100644 index 000000000..4a493202a --- /dev/null +++ b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-fluid.scss @@ -0,0 +1,21 @@ +@use "../../../_utils/utils" as *; + +/// Fluid popover +/// +/// @type state +/// @author WPMU DEV +/// +/// @param {String} $block - Main block name +@mixin edit-fluid($block) { + // DIR: Left to right. + // THEME: None. + @include sui-class($rtl: false, $theme: null) { + @include block($block) { + @include element(popover) { + @include modifier(fluid) { + min-width: 100%; + } + } + } + } +} diff --git a/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-type.scss b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-type.scss new file mode 100644 index 000000000..06e664e1c --- /dev/null +++ b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-type.scss @@ -0,0 +1,36 @@ +@use "../../../_utils/utils" as *; + +/// Edit Type +/// +/// @type state +/// @author WPMU DEV +/// +/// @param {String} $block - Main block name +@mixin edit-type($block) { + @include sui-class($rtl: false, $theme: null) { + @include block($block) { + @include element(popover) { + @include modifier(select) { + width: 100%; + } + + @include modifier(select-checkbox) { + width: 100%; + } + } + } + } + + @include sui-class($rtl: false, $theme: dark) { + @include block($block) { + @include element(menu-item) { + .sui-#{$block}__menu-item-icon { + color: inherit; + } + @include modifier(hover) { + background: $color-extended-neutral-0; + } + } + } + } +} diff --git a/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-variation.scss b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-variation.scss index fd68c1f63..b1d9fe9c1 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-variation.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_states/_edit-variation.scss @@ -141,6 +141,20 @@ background: $color-secondary-beehive-general-70; } } + + @include modifier(danger) { + &:not(.sui-#{$block}__menu-item--disabled) { + color: #f45c59; + + .sui-#{$block}__menu-item-icon { + color: #f45c59; + } + + &.sui-#{$block}__menu-item--hover { + background: #feefee; + } + } + } } } } diff --git a/packages/assets/css/src/scss/_components/_dropdown/_states/_states.scss b/packages/assets/css/src/scss/_components/_dropdown/_states/_states.scss index cfa3714be..6d5b11cc0 100644 --- a/packages/assets/css/src/scss/_components/_dropdown/_states/_states.scss +++ b/packages/assets/css/src/scss/_components/_dropdown/_states/_states.scss @@ -2,3 +2,6 @@ @forward "./edit-disabled"; @forward "./edit-hover"; @forward "./edit-variation"; +@forward "./edit-fluid"; +@forward "./edit-current"; +@forward "./edit-type"; diff --git a/packages/assets/css/src/scss/_components/_input/_elements/_build-input.scss b/packages/assets/css/src/scss/_components/_input/_elements/_build-input.scss index 36473c151..2e4400006 100644 --- a/packages/assets/css/src/scss/_components/_input/_elements/_build-input.scss +++ b/packages/assets/css/src/scss/_components/_input/_elements/_build-input.scss @@ -48,6 +48,7 @@ background: transparent; border: none; outline: none; + padding: $spacing-md; } } diff --git a/packages/assets/css/src/scss/_components/_navigation/_elements/_navigation-container.scss b/packages/assets/css/src/scss/_components/_navigation/_elements/_navigation-container.scss index 7e0429d4e..31a286387 100644 --- a/packages/assets/css/src/scss/_components/_navigation/_elements/_navigation-container.scss +++ b/packages/assets/css/src/scss/_components/_navigation/_elements/_navigation-container.scss @@ -8,32 +8,49 @@ /// /// @param {String} $block - Main block name @mixin build-container($block) { - // DIR: Left to right. - // THEME: None. - @include sui-class($rtl: false, $theme: null) { - @include block($block) { - align-items: center; - background-color: $color-extended-neutral-100; - display: flex; - gap: $spacing-lg; - justify-content: space-between; - padding: $spacing-lg $spacing-xxl; + // DIR: Left to right. + // THEME: None. + @include sui-class($rtl: false, $theme: null) { + @include block($block) { + align-items: center; + background-color: $color-extended-neutral-100; + display: flex; + gap: $spacing-lg; + justify-content: space-between; + padding: $spacing-lg $spacing-xxl; - @include media(max-width, md) { - padding: $spacing-xl; - } - - @include media(max-width, sm) { - padding: $spacing-lg; - } - } - } + .sui-dropdown__popover { + .sui-dropdown__menu-group-title { + padding: $spacing-md $spacing-xl; + } - // DIR: Left to right. - // THEME: Dark. - @include sui-class($rtl: false, $theme: dark) { - @include block($block) { - border: $border-width-sm solid $color-extended-neutral-0; - } - } + .sui-dropdown__menu-item { + padding: $spacing-md $spacing-xl; + font: $font-weight-md #{$font-size-default}/#{$font-height-sm} + $font-family-default; + color: $color-extended-neutral-50; + + &.sui-dropdown__menu-item--hover { + color: $color-extended-neutral-0; + } + } + } + + @include media(max-width, md) { + padding: $spacing-xl; + } + + @include media(max-width, sm) { + padding: $spacing-lg; + } + } + } + + // DIR: Left to right. + // THEME: Dark. + @include sui-class($rtl: false, $theme: dark) { + @include block($block) { + border: $border-width-sm solid $color-extended-neutral-0; + } + } } diff --git a/packages/assets/css/src/scss/_components/_search/_elements/_search-container.scss b/packages/assets/css/src/scss/_components/_search/_elements/_search-container.scss index 607efebda..59bdf0052 100644 --- a/packages/assets/css/src/scss/_components/_search/_elements/_search-container.scss +++ b/packages/assets/css/src/scss/_components/_search/_elements/_search-container.scss @@ -13,27 +13,17 @@ @include sui-class($rtl: false, $theme: null) { @include block($block) { position: relative; - border: $border-width-sm solid $color-extended-neutral-70; - border-radius: $border-radius-sm; background: $color-extended-neutral-100; - &:after { - content: ""; - position: absolute; - inset: -#{$border-width-md}; - pointer-events: none; - } - - @include modifier(hover) { - border: $border-width-sm solid $color-primary-50; - } + .sui-#{$block}__input-field { + border: $border-width-sm solid $color-extended-neutral-70; + border-radius: $border-radius-sm; - @include modifier(focus) { &:after { - border-width: $border-width-md; - border-style: solid; - border-color: $color-primary-50; - border-radius: inherit; + content: ""; + position: absolute; + inset: -#{$border-width-md}; + pointer-events: none; } } } @@ -43,12 +33,8 @@ // THEME: None. @include sui-class($rtl: false, $theme: dark) { @include block($block) { - border-color: $color-extended-neutral-0; - - @include modifier(hover, focus) { - &:after { - border-color: inherit; - } + .sui-#{$block}__input-field { + border-color: $color-extended-neutral-0; } } } diff --git a/packages/assets/css/src/scss/_components/_search/_elements/_search-hint.scss b/packages/assets/css/src/scss/_components/_search/_elements/_search-hint.scss index 4fc726112..76cdbcbbb 100644 --- a/packages/assets/css/src/scss/_components/_search/_elements/_search-hint.scss +++ b/packages/assets/css/src/scss/_components/_search/_elements/_search-hint.scss @@ -12,12 +12,21 @@ // THEME: None. @include sui-class($rtl: false, $theme: null) { @include block($block) { + @include element(hint-wrapper) { + padding: $spacing-md; + } + @include element(hint) { + display: flex; + align-items: center; + font-size: $font-size-xs; + line-height: $font-height-xs; + gap: $spacing-md; border-radius: $border-radius-sm; - background: $color-primary-90; + background: $color-extended-neutral-95; color: $color-extended-neutral-50; - margin: $spacing-md; padding: $spacing-lg; + border-radius: $border-radius-sm; } } } diff --git a/packages/assets/css/src/scss/_components/_search/_elements/_search-options.scss b/packages/assets/css/src/scss/_components/_search/_elements/_search-options.scss index 56970273f..8542a0a59 100644 --- a/packages/assets/css/src/scss/_components/_search/_elements/_search-options.scss +++ b/packages/assets/css/src/scss/_components/_search/_elements/_search-options.scss @@ -12,8 +12,14 @@ // THEME: None. @include sui-class($rtl: false, $theme: null) { @include block($block) { + @include element(popover) { + border-radius: $border-radius-md; + box-shadow: 0 $spacing-xs $spacing-sm 0 $color-shadow-dark, + 0 $spacing-2xs $spacing-lg 0 $color-shadow-light, + 0 #{$spacing-sm + 2px} $spacing-default 0 $color-shadow-default; + } + @include element(options) { - border-top: solid $border-width-sm $color-primary-80; margin: 0 -#{$border-width-sm} -#{$border-width-sm}; max-height: $search-size-height-md; overflow-y: auto; @@ -60,10 +66,6 @@ // THEME: Dark. @include sui-class($rtl: false, $theme: dark) { @include block($block) { - @include element(options) { - border-color: $color-extended-neutral-0; - } - @include element(options-item) { @include modifier(hover, focus) { background: $color-extended-neutral-0; diff --git a/packages/assets/css/src/scss/_components/_search/_search.scss b/packages/assets/css/src/scss/_components/_search/_search.scss index 69f28e417..61bb3892a 100644 --- a/packages/assets/css/src/scss/_components/_search/_search.scss +++ b/packages/assets/css/src/scss/_components/_search/_search.scss @@ -6,4 +6,6 @@ @include sui-search--element.build-options(search); @include sui-search--element.build-input-field(search); +@include sui-search--state.state-hover(search); +@include sui-search--state.state-focus(search); @include sui-search--state.state-disabled(search); diff --git a/packages/assets/css/src/scss/_components/_search/_states/_state-focus.scss b/packages/assets/css/src/scss/_components/_search/_states/_state-focus.scss new file mode 100644 index 000000000..d3cee6c7f --- /dev/null +++ b/packages/assets/css/src/scss/_components/_search/_states/_state-focus.scss @@ -0,0 +1,42 @@ +@use "sass:map"; + +@use "../../../_utils/utils" as *; + +/// Select Focus +/// +/// @type state +/// @author WPMU DEV +/// +/// @param {String} $block - Main block name +@mixin state-focus($block) { + // DIR: Left to right. + // THEME: None. + @include sui-class($rtl: false, $theme: null) { + @include block($block) { + @include modifier(focus) { + .sui-#{$block}__input-field { + &:after { + border-width: $border-width-md; + border-style: solid; + border-color: $color-primary-50; + border-radius: inherit; + } + } + } + } + } + + // DIR: Left to right. + // THEME: None. + @include sui-class($rtl: false, $theme: dark) { + @include block($block) { + @include modifier(focus) { + .sui-#{$block}__input-field { + &:after { + border-color: inherit; + } + } + } + } + } +} diff --git a/packages/assets/css/src/scss/_components/_search/_states/_state-hover.scss b/packages/assets/css/src/scss/_components/_search/_states/_state-hover.scss new file mode 100644 index 000000000..5d7820a9d --- /dev/null +++ b/packages/assets/css/src/scss/_components/_search/_states/_state-hover.scss @@ -0,0 +1,37 @@ +@use "sass:map"; + +@use "../../../_utils/utils" as *; + +/// Select Hover +/// +/// @type state +/// @author WPMU DEV +/// +/// @param {String} $block - Main block name +@mixin state-hover($block) { + // DIR: Left to right. + // THEME: None. + @include sui-class($rtl: false, $theme: null) { + @include block($block) { + @include modifier(hover) { + .sui-#{$block}__input-field { + border: $border-width-sm solid $color-primary-50; + } + } + } + } + + // DIR: Left to right. + // THEME: None. + @include sui-class($rtl: false, $theme: dark) { + @include block($block) { + @include modifier(hover) { + .sui-#{$block}__input-field { + &:after { + border-color: inherit; + } + } + } + } + } +} diff --git a/packages/assets/css/src/scss/_components/_search/_states/_states.scss b/packages/assets/css/src/scss/_components/_search/_states/_states.scss index de423593c..c2ae05a6c 100644 --- a/packages/assets/css/src/scss/_components/_search/_states/_states.scss +++ b/packages/assets/css/src/scss/_components/_search/_states/_states.scss @@ -1 +1,3 @@ @forward "./state-disabled"; +@forward "./state-focus"; +@forward "./state-hover"; diff --git a/packages/assets/css/src/scss/_components/_select/_elements/_build-control.scss b/packages/assets/css/src/scss/_components/_select/_elements/_build-control.scss index dde9339ea..37f256526 100644 --- a/packages/assets/css/src/scss/_components/_select/_elements/_build-control.scss +++ b/packages/assets/css/src/scss/_components/_select/_elements/_build-control.scss @@ -21,7 +21,6 @@ border-radius: $select-control-border-radius; padding: $select-control-padding-md-vertical $select-control-padding-md-horizontal; - // min-height: $select-control-size-md; // Causes a height issue in THC cursor: pointer; &::after { @@ -48,9 +47,18 @@ @include modifies-element(control) { padding: $select-control-searchable-padding; } + + .sui-select__dropdown--content { + display: block; + } } @include modifier(multiselect) { + .sui-dropdown__popover { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + @include modifies-element(selected-options) { position: relative; display: inline-block; @@ -114,6 +122,10 @@ } @include modifier(multiselect) { + .sui-dropdown__popover { + border-top: 0; + } + @include modifies-element(selected-options) { color: $color-extended-neutral-100; background: $color-extended-neutral-0; diff --git a/packages/assets/css/src/scss/_components/_select/_elements/_build-dropdown.scss b/packages/assets/css/src/scss/_components/_select/_elements/_build-dropdown.scss index 819cb46c5..3fc640bbb 100644 --- a/packages/assets/css/src/scss/_components/_select/_elements/_build-dropdown.scss +++ b/packages/assets/css/src/scss/_components/_select/_elements/_build-dropdown.scss @@ -14,18 +14,25 @@ @include sui-class($rtl: false, $theme: null) { @include block($block) { @include element(dropdown) { - position: absolute; - left: 0; - right: 0; - overflow-y: auto; - background: $color-extended-neutral-100; - padding: $select-dropdown-container-padding-vertical - $select-dropdown-container-padding-horizontal; - margin: $select-dropdown-container-margin; - border-style: $select-dropdown-container-border-style; - border-bottom-left-radius: $select-dropdown-container-border-radius; - border-bottom-right-radius: $select-dropdown-container-border-radius; - max-height: $select-dropdown-container-size-height; + border-radius: $border-radius-md; + width: 100%; + + &.sui-dropdown { + position: absolute; + height: 100%; + top: 0; + z-index: -1; + } + + .sui-dropdown__popover { + margin: 0; + width: 100%; + z-index: 1; + + ul { + margin: 0; + } + } @include modifier(option) { display: flex; @@ -38,8 +45,19 @@ margin: $select-dropdown-option-margin; } - @include modifier(selected) { - color: $color-primary-50; + @include modifier(content) { + display: flex; + + .sui-checkbox { + width: auto !important; + } + } + + .sui-dropdown__menu { + .sui-dropdown__menu-item--selected, + .sui-select__dropdown--selected { + color: $color-primary-50; + } } @include modifier(empty) { @@ -53,14 +71,6 @@ border-radius: $select-dropdown-empty-border-radius; } } - - @include modifier(open) { - @include modifies-element(dropdown) { - border-top: 0; - border-width: $select-dropdown-open-border-width; - border-color: $select-dropdown-open-border-color; - } - } } } @@ -69,8 +79,11 @@ @include sui-class($rtl: false, $theme: dark) { @include block($block) { @include element(dropdown) { - @include modifier(selected) { - color: $color-extended-neutral-0; + .sui-dropdown__menu { + .sui-select__dropdown--selected, + .sui-dropdown__menu-item--selected { + color: $color-extended-neutral-0; + } } @include modifier(empty) { diff --git a/packages/assets/css/src/scss/_components/_select/_elements/_build-search.scss b/packages/assets/css/src/scss/_components/_select/_elements/_build-search.scss index c7e5cbb33..56a962841 100644 --- a/packages/assets/css/src/scss/_components/_select/_elements/_build-search.scss +++ b/packages/assets/css/src/scss/_components/_select/_elements/_build-search.scss @@ -19,23 +19,22 @@ border-top: 0; padding: $select-search-padding-horizontal $select-search-padding-vertical 0; - border-style: $select-search-border-style; - border-width: $select-search-border-width; - border-color: $select-search-border-color; } @include modifier(multiselect) { @include modifies-element(search) { + display: none; position: absolute; left: 0; right: 0; border-top: 0; border-bottom: 0; background: $color-extended-neutral-100; - } - - @include modifies-element(dropdown) { - margin-top: $select-dropdown-margin-md; + border-top-left-radius: $border-radius-md; + border-top-right-radius: $border-radius-md; + box-shadow: 0 $spacing-xs $spacing-sm 0 $color-shadow-dark, + 0 $spacing-2xs $spacing-lg 0 $color-shadow-light, + 0 #{$spacing-sm + 2px} $spacing-default 0 $color-shadow-default; } } } @@ -47,6 +46,10 @@ @include block($block) { @include element(search) { border-color: $color-extended-neutral-0; + border-top: $border-width-sm; + border-left: $border-width-sm; + border-right: $border-width-sm; + border-style: solid; } } } diff --git a/packages/assets/css/src/scss/_components/_select/_modifiers/_select-size.scss b/packages/assets/css/src/scss/_components/_select/_modifiers/_select-size.scss index 8851e6ca9..ffe166602 100644 --- a/packages/assets/css/src/scss/_components/_select/_modifiers/_select-size.scss +++ b/packages/assets/css/src/scss/_components/_select/_modifiers/_select-size.scss @@ -28,6 +28,11 @@ } } + .sui-dropdown__menu-item { + font-size: $select-input-default-font-size-sm; + line-height: $select-input-default-font-height-sm; + } + &.sui-#{$block}--multiselect { .sui-#{$block}__control { padding-right: $select-control-multiselect-padding-sm-horizontal; @@ -74,11 +79,18 @@ .sui-checkbox { font-size: $select-checkbox-font-size-sm; + line-height: $select-input-default-font-height-sm; .sui-checkbox__box { width: $select-checkbox-size-sm; height: $select-checkbox-size-sm; } } + + .sui-dropdown__popover--#{$block}-checkbox { + .sui-dropdown__menu-item { + padding: $spacing-sm $spacing-lg; + } + } } } } diff --git a/packages/assets/css/src/scss/_components/_select/_states/_state-hover.scss b/packages/assets/css/src/scss/_components/_select/_states/_state-hover.scss index 9d8b8f50a..9067dde48 100644 --- a/packages/assets/css/src/scss/_components/_select/_states/_state-hover.scss +++ b/packages/assets/css/src/scss/_components/_select/_states/_state-hover.scss @@ -21,13 +21,24 @@ } @include element(dropdown) { - @include modifier(option) { - &:hover { + .sui-dropdown__menu { + .sui-dropdown__menu-item--hover { background: $color-primary-50; color: $color-extended-neutral-100; } } } + + @include modifier(multiselect) { + @include modifies-element(dropdown) { + .sui-dropdown__menu { + .sui-dropdown__menu-item--hover { + background: none; + color: unset; + } + } + } + } } } @@ -42,16 +53,27 @@ } @include element(dropdown) { - @include modifier(option) { - &:hover { + .sui-dropdown__menu { + .sui-dropdown__menu-item--hover { background: $color-extended-neutral-0; color: $color-extended-neutral-100; } + + .sui-select__dropdown--selected { + &.sui-dropdown__menu-item--hover { + color: $color-extended-neutral-100; + } + } } + } - @include modifier(selected) { - &:hover { - color: $color-extended-neutral-100; + @include modifier(multiselect) { + @include modifies-element(dropdown) { + .sui-dropdown__menu { + .sui-dropdown__menu-item--hover { + background: none; + color: unset; + } } } } diff --git a/packages/assets/css/src/scss/_components/_select/_states/_state-open.scss b/packages/assets/css/src/scss/_components/_select/_states/_state-open.scss index 4bd099138..ebaf41df8 100644 --- a/packages/assets/css/src/scss/_components/_select/_states/_state-open.scss +++ b/packages/assets/css/src/scss/_components/_select/_states/_state-open.scss @@ -16,15 +16,8 @@ @include modifier(open) { z-index: 1; @include modifies-element(control) { - border-bottom-color: transparent; - box-shadow: inset 0px -#{$shadow-offset-2xs} 0px $color-primary-80; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; &::after { border: $border-width-md solid $color-primary-50; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom: 0; } } @@ -34,6 +27,10 @@ border-bottom-color: transparent; box-shadow: inset 0px -#{$shadow-offset-2xs} 0px $color-primary-80; } + + @include modifies-element(search) { + display: block; + } } } } diff --git a/packages/assets/css/src/scss/_components/_spinner/_elements/_build-loader.scss b/packages/assets/css/src/scss/_components/_spinner/_elements/_build-loader.scss index b0009d853..983ed686b 100644 --- a/packages/assets/css/src/scss/_components/_spinner/_elements/_build-loader.scss +++ b/packages/assets/css/src/scss/_components/_spinner/_elements/_build-loader.scss @@ -55,7 +55,7 @@ } @include modifies-element(icon--stroke) { - stroke: $color-extended-neutral-100; + stroke: $color-extended-neutral-0; } } } @@ -75,7 +75,6 @@ @include modifier(dark) { @include modifies-element(icon--stroke) { stroke-width: $spinner-stroke-width-md; - stroke: $color-extended-neutral-0; } } } diff --git a/packages/assets/css/src/scss/_components/_spinner/_elements/_build-overlay.scss b/packages/assets/css/src/scss/_components/_spinner/_elements/_build-overlay.scss index 1f1f819bf..17b6cd6c0 100644 --- a/packages/assets/css/src/scss/_components/_spinner/_elements/_build-overlay.scss +++ b/packages/assets/css/src/scss/_components/_spinner/_elements/_build-overlay.scss @@ -28,6 +28,10 @@ @include modifier(dark) { background-color: $color-alpha-grey-70; + + + .sui-#{$block} .sui-#{$block}__icon--stroke { + stroke: $color-extended-neutral-100; + } } } } diff --git a/packages/assets/css/src/scss/_components/_table/_table-elements/_build-table-toolbar.scss b/packages/assets/css/src/scss/_components/_table/_table-elements/_build-table-toolbar.scss index 7d880e09e..e2918fa08 100644 --- a/packages/assets/css/src/scss/_components/_table/_table-elements/_build-table-toolbar.scss +++ b/packages/assets/css/src/scss/_components/_table/_table-elements/_build-table-toolbar.scss @@ -62,7 +62,7 @@ @include element(toolbar-filter) { margin: 0; - .sui-dropdown__menu { + > .sui-dropdown__menu { width: $table-width-md; } diff --git a/packages/assets/css/src/scss/_utils/_tokens.scss b/packages/assets/css/src/scss/_utils/_tokens.scss index 5da0a1ca5..7e152003c 100644 --- a/packages/assets/css/src/scss/_utils/_tokens.scss +++ b/packages/assets/css/src/scss/_utils/_tokens.scss @@ -344,7 +344,7 @@ $drawer-size-sm: 320px; $drawer-size-lg: 520px; $dropdown-size-width-sm: 280px; $dropdown-size-width-md: 300px; -$dropdown-size-height-md: 300px; +$dropdown-size-height-md: 225px; $dropdown-spacing-default: 12px; $emptystate-spacing-sm: 24px; $emptystate-spacing-md: 32px; diff --git a/packages/assets/css/src/tokens/dropdown.json b/packages/assets/css/src/tokens/dropdown.json index 6d4510d4e..aebd7e2d2 100644 --- a/packages/assets/css/src/tokens/dropdown.json +++ b/packages/assets/css/src/tokens/dropdown.json @@ -13,7 +13,7 @@ }, "height": { "md": { - "value": "300px", + "value": "225px", "type": "sizing" } } diff --git a/packages/docs/src/components/message/message.tsx b/packages/docs/src/components/message/message.tsx index ce38ecb49..a8478a576 100644 --- a/packages/docs/src/components/message/message.tsx +++ b/packages/docs/src/components/message/message.tsx @@ -34,7 +34,7 @@ const Message: React.FunctionComponent< const messageClasses = classnames({ "csb-message": true, [`csb-message--${color}`]: !isEmpty(color), - [className as string]: !!className, + [(className ?? "") as string]: !!className, }) const cta = Object.assign( diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 066c61391..739daacf0 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -26,6 +26,12 @@ export { useDefaultChildren } from "./use-default-children" // detect browser export { useDetectBrowser } from "./use-detect-browser" +// detect scroll bottom +export { useBottomEnd } from "./use-bottom-end" + +// useDebounce +export { useDebounce } from "./use-debounce" + // useStyles export * from "./use-styles" diff --git a/packages/hooks/src/use-bottom-end.ts b/packages/hooks/src/use-bottom-end.ts new file mode 100644 index 000000000..479acd50d --- /dev/null +++ b/packages/hooks/src/use-bottom-end.ts @@ -0,0 +1,23 @@ +import React, { useCallback, useEffect, RefObject, Ref } from "react" + +/** + * Detect bottom end + * + * @param {Function} onBottomReach actions to be executed when scroll hit bottom + */ +const useBottomEnd = (onBottomReach = () => {}) => { + const handleScroll: React.UIEventHandler< + HTMLDivElement | HTMLUListElement + > = (e) => { + // @ts-ignore + const { scrollHeight, scrollTop, clientHeight } = e?.target ?? {} + + if (scrollHeight - scrollTop === clientHeight) { + onBottomReach() + } + } + + return { handleScroll } +} + +export { useBottomEnd } diff --git a/packages/hooks/src/use-debounce.ts b/packages/hooks/src/use-debounce.ts new file mode 100644 index 000000000..937605776 --- /dev/null +++ b/packages/hooks/src/use-debounce.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react" + +const useDebounce = (value: T, delay: number, onChange = () => {}): T => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + onChange() + }, delay) + + // cleanup function to clear the timeout when the value changes before the delay + return () => { + clearTimeout(handler) + } + }, [value, delay, onChange]) + + return debouncedValue +} + +export { useDebounce } diff --git a/packages/ui/checkbox/src/checkbox.tsx b/packages/ui/checkbox/src/checkbox.tsx index efa5b7b01..163047ebc 100644 --- a/packages/ui/checkbox/src/checkbox.tsx +++ b/packages/ui/checkbox/src/checkbox.tsx @@ -100,8 +100,8 @@ const Checkbox = ({ > {/* Checkbox input */} {/* Render Indeterminate or Tick component based on isIndeterminate */} diff --git a/packages/ui/checkbox/src/checkbox.types.ts b/packages/ui/checkbox/src/checkbox.types.ts index efe3d7bdc..ccf8cb794 100644 --- a/packages/ui/checkbox/src/checkbox.types.ts +++ b/packages/ui/checkbox/src/checkbox.types.ts @@ -101,7 +101,7 @@ interface CheckboxProps /** * The label for the checkbox. */ - label?: string + label?: ReactNode /** * Represents the value of the checkbox diff --git a/packages/ui/color-picker/src/elements/picker.tsx b/packages/ui/color-picker/src/elements/picker.tsx index ba906342d..9adee987c 100644 --- a/packages/ui/color-picker/src/elements/picker.tsx +++ b/packages/ui/color-picker/src/elements/picker.tsx @@ -178,6 +178,7 @@ const Picker: React.FC = ({
= ({
= ({
= ({ className = "", children, isDisabled, + isSelected = false, onClick, variation = "", + variable, + description = "", + _type = "", _htmlProps = {}, _style = {}, + _checkboxProps = {}, }) => { // Use the useInteraction hook to manage hover and focus states const [isHovered, isFocused, methods] = useInteraction({}) @@ -47,6 +53,7 @@ const DropdownMenuItem: FC = ({ hover: isHovered, focus: isFocused, disabled: isDisabled, + selected: isSelected, [variation]: !isEmpty(variation), }, suiInlineClassname, @@ -59,19 +66,66 @@ const DropdownMenuItem: FC = ({ IconTag = Icons[icon] } + const getContent = () => { + let content = {children} + + switch (_type) { + case "select": + break + case "select-checkbox": + content = ( + + ) + break + case "select-variable": + IconTag = IconTag ? IconTag : Icons.Add + content = ( +
+
+ +
+
+ {children} + {variable && ( + + {` `} + {variable} + + )} + {description && ( +
+ {description} +
+ )} +
+
+ ) + break + } + + if ("select-variable" !== _type && !!IconTag) { + content = ( + <> + + {content} + + ) + } + + return content + } + // Prepare attributes for the menu item element const attrs = { className: classNames, href: !!href ? href : undefined, tabIndex: isDisabled ? -1 : 0, - children: ( - <> - {!!IconTag && ( - - )} - {children} - - ), + children: getContent(), ..._renderHTMLPropsSafely(_htmlProps), } diff --git a/packages/ui/dropdown/src/dropdown-menu.tsx b/packages/ui/dropdown/src/dropdown-menu.tsx index 2407c0c05..34f1232c6 100644 --- a/packages/ui/dropdown/src/dropdown-menu.tsx +++ b/packages/ui/dropdown/src/dropdown-menu.tsx @@ -14,7 +14,7 @@ const DropdownMenu: React.FC = ({ const classNames = generateCN("sui-dropdown__menu", {}, suiInlineClassname) // Render the Menu component with the provided children - return
    {children}
+ return
{children}
} export { DropdownMenu } diff --git a/packages/ui/dropdown/src/dropdown.tsx b/packages/ui/dropdown/src/dropdown.tsx index 0812c863e..fd9304d69 100644 --- a/packages/ui/dropdown/src/dropdown.tsx +++ b/packages/ui/dropdown/src/dropdown.tsx @@ -5,15 +5,31 @@ import React, { forwardRef, useImperativeHandle, ChangeEvent, + useCallback, + useEffect, } from "react" import { _renderHTMLPropsSafely, generateCN, isEmpty } from "@wpmudev/sui-utils" import { Button, ButtonProps } from "@wpmudev/sui-button" -import { useOuterClick, useStyles } from "@wpmudev/sui-hooks" +import { + useOuterClick, + useStyles, + useBottomEnd, + usePrevious, + useDebounce, +} from "@wpmudev/sui-hooks" import { DropdownMenu } from "./dropdown-menu" import { DropdownMenuItem } from "./dropdown-menu-item" import { DropdownMenuGroup } from "./dropdown-menu-group" -import { DropdownProps, DropdownRefProps } from "./dropdown.types" +import { + DropdownProps, + DropdownRefProps, + MenuGroupProps, + MenuItemProps, +} from "./dropdown.types" +import { Input } from "@wpmudev/sui-input" +import { Spinner } from "@wpmudev/sui-spinner" +import { isSameDay } from "date-fns" /** * Dropdown Component - A reusable dropdown UI component. @@ -24,6 +40,7 @@ import { DropdownProps, DropdownRefProps } from "./dropdown.types" const Dropdown = forwardRef( ( { + type = "", label, className, isSmall = false, @@ -37,34 +54,74 @@ const Dropdown = forwardRef( trigger, renderContentOnTop = false, isResponsive = false, + isFluid = false, + closeOnOuterClick = true, colorScheme = "black", + onToggle = () => {}, + // search + allowSearch = false, + onSearch = (query: string) => {}, + // async + isAsync = false, + asyncOptions = {}, + getOptions, + menuCustomWidth, + searchPlaceholder, _htmlProps = {}, _style = {}, }, ref, ) => { + // State to manage the dropdown's open/closed status. + const [isOpen, setIsOpen] = useState(false) + // Set search query + const [query, setQuery] = useState("") + const [isFetchedAll, setIsFetchedAll] = useState(false) + // Set loader when loading options from API + const [isLoading, setIsLoading] = useState(false) + // set alternate loading style + const [altLoader, setAltLoader] = useState(false) + // Dropdown options list + const [options, setOptions] = useState(menu) + // Holds current page number (when loading options from API) + const [page, setPage] = useState(1) // Create a ref to access the dropdown's outer container element. const dropdownRef = useRef(null) - + const popoverRef = useRef(null) + const searchInputRef = useRef(null) // Generate a unique identifier for the dropdown component. const id = `sui-dropdown-${useId()}` - // State to manage the dropdown's open/closed status. - const [isOpen, setIsOpen] = useState(false) - // Handle the closing of the dropdown when clicking outside the component. useOuterClick(dropdownRef, () => { - setIsOpen(false) + if (closeOnOuterClick) { + handleOnOpen(false) + } + }) + + const { handleScroll } = useBottomEnd(() => { + if (!isLoading && !isFetchedAll) { + loadFromAPI() + setAltLoader(true) + } }) useImperativeHandle(ref, () => ({ - open: () => setIsOpen(true), - close: () => setIsOpen(false), - toggle: () => setIsOpen(!isOpen), + open: () => handleOnOpen(true), + close: () => handleOnOpen(false), + toggle: () => handleOnOpen(!isOpen), })) const { suiInlineClassname } = useStyles(_style, className) + const searchQuery = useDebounce(query, 500, () => { + // Reset fetched all flag and page to 1 + if (isAsync) { + setPage(1) + setIsFetchedAll(false) + } + }) + // Generate classes for the dropdown's wrapper based on the component's props. const wrapperClasses = generateCN( "sui-dropdown", @@ -75,33 +132,188 @@ const Dropdown = forwardRef( suiInlineClassname, ) + // show dropdown on top/bottom based on the space available + useEffect(() => { + if (!isOpen || !popoverRef.current || !dropdownRef.current) return + + const popoverElement = popoverRef.current + const triggerElement = dropdownRef.current + + const triggerRect = triggerElement.getBoundingClientRect() + + // Calculate the space available above and below the trigger button + const spaceAbove = triggerRect.top + const spaceBelow = window.innerHeight - triggerRect.bottom + + // Get the height of the popover + const popoverHeight = popoverElement.offsetHeight + + // Determine if the popover height fits in the space below + const showBelow = spaceBelow > popoverHeight + + // Determine if the space above is limited + const spaceAboveLimited = spaceAbove < popoverHeight + + // Set the appropriate CSS class for placement + popoverElement.classList.toggle( + "sui-dropdown__popover--placement-top", + !showBelow && !spaceAboveLimited, + ) + }, [isOpen]) + + // Update internal options state when menu prop changes + useEffect(() => { + if (!isAsync) { + setOptions(menu) + } + }, [isAsync, menu]) + + /** + * Load options from next page + */ + const loadFromAPI = useCallback(async () => { + // Do not continue + if (!isAsync || isFetchedAll || isLoading) { + return + } + + // return if getOptions prop is missing + if (!getOptions) { + throw new Error("'getOptions' method is missing") + return + } + + const { perPage = 5 } = asyncOptions ?? {} + + // Enable loader + setIsLoading(true) + + const opt = { page } + + // Get options from API (to be hanlded in parent component) + const data = await getOptions(searchQuery, opt, options) + const { items, hasMore, additional } = data + + // Update options list + setOptions(1 === page ? items : [...(options ?? []), ...items]) + setIsLoading(false) + setAltLoader(false) + + // Increase page + if (hasMore) { + setPage(page + 1) + } else { + setIsFetchedAll(true) + } + }, [ + isAsync, + isFetchedAll, + isLoading, + getOptions, + asyncOptions, + page, + searchQuery, + options, + ]) + + // prev search query + const prevQuery = usePrevious(searchQuery) + + useEffect(() => { + // Do nothing if same query detected + if ((prevQuery ?? "") !== searchQuery) { + // when isAsync is enabled then load from API + if (isAsync && !isLoading) { + setOptions([]) + loadFromAPI() + } + + if (!!onSearch) { + onSearch(searchQuery) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, onSearch]) + + /** + * Handle open and close actions + */ + const handleOnOpen = useCallback( + async (isDropdownOpen: boolean) => { + setIsOpen(isDropdownOpen) + + // Focus search input when dropdown opens + if (allowSearch) { + setTimeout(() => searchInputRef.current?.focus(), 100) + } + + // load options + if (!!isAsync && isDropdownOpen) { + loadFromAPI() + } + + // Pass state to parent component + onToggle(isDropdownOpen) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isAsync, onToggle], + ) + // Function to recursively render menu items and groups. - const renderMenus = (menus: Record[]) => { - return (menus || [])?.map((menuItem, index) => { + const renderMenus = (menus: DropdownProps["menu"]) => { + return (menus || [])?.map((menuItem: Record, index) => { // If it's a group item, render the MenuGroup component. - if (!!menuItem.menus) { + if (!!menuItem?.menus) { return ( - {renderMenus(menuItem.menus)} + {renderMenus(menuItem?.menus)} ) } // Bind onClick with onMenuClick prop if (onMenuClick) { - menuItem.props.onClick = (e: ChangeEvent) => - onMenuClick(menuItem.id, e) + menuItem.props = menuItem.props ?? {} + menuItem.props.onClick = (e: ChangeEvent) => { + onMenuClick(menuItem, e) + // Update isSelected property of all menu items + const updatedOptions = options?.map((item) => ({ + ...item, + isSelected: item.id === menuItem.id, // Set the clicked item's isSelected to true, and others to false + })) + setOptions(updatedOptions) + + menuItem.isSelected = true + if ("select-checkbox" !== type) { + setIsOpen(false) + } + } } // Otherwise, render the MenuItem component. return ( - + {menuItem.label} ) }) } + /** + * Search input callback + */ + const onSearchCallback = useCallback( + (event: ChangeEvent) => { + setQuery(event?.target?.value) + }, + [], + ) + return (
( iconOnly={iconOnly ?? false} type="secondary" isSmall={isSmall ?? false} - onClick={() => setIsOpen(!isOpen)} + onClick={() => handleOnOpen(!isOpen)} isResponsive={isResponsive} {...(!iconOnly && { endIcon: "ChevronDown" })} colorScheme={colorScheme as ButtonProps["colorScheme"]} @@ -130,22 +342,58 @@ const Dropdown = forwardRef(
{renderContentOnTop && !!children && (
{children}
)} {/* Render the dropdown menu items */} - {!!menu && ( - - {renderMenus(menu)} + {(!!menu || isAsync) && ( + + {allowSearch && ( +
+ +
+ )} + +
    + {renderMenus(options)} + {isLoading && ( +
  • + + {altLoader ? "Loading..." : "Loading"} +
  • + )} +
)} {/* Render additional children passed to the Dropdown component */} diff --git a/packages/ui/dropdown/src/dropdown.types.ts b/packages/ui/dropdown/src/dropdown.types.ts index 58381ef44..1427b1ac8 100644 --- a/packages/ui/dropdown/src/dropdown.types.ts +++ b/packages/ui/dropdown/src/dropdown.types.ts @@ -1,4 +1,9 @@ -import React, { HTMLProps, KeyboardEvent } from "react" +import React, { + CSSProperties, + HTMLProps, + KeyboardEvent, + ReactNode, +} from "react" import { IconsNamesType } from "@wpmudev/sui-icons" import { ButtonProps } from "@wpmudev/sui-button" import { @@ -6,6 +11,7 @@ import { SuiHTMLAttributes, SuiStyleType, } from "@wpmudev/sui-utils" +import { CheckboxProps } from "@wpmudev/sui-checkbox" /** * Props for Menu component. @@ -21,6 +27,7 @@ interface DropdownMenuProps extends SuiStyleType { * Props for MenuItem component. */ interface DropdownMenuItemProps extends SuiStyleType, SuiHTMLAttributes { + _type?: DropdownProps["type"] /** * URL to navigate to when the item is clicked (if the item is an anchor). */ @@ -37,6 +44,10 @@ interface DropdownMenuItemProps extends SuiStyleType, SuiHTMLAttributes { * Makes dropdown disabled */ isDisabled?: boolean + /** + * Makes dropdown disabled + */ + isSelected?: boolean /** * Dropdown menu item variation */ @@ -52,6 +63,7 @@ interface DropdownMenuItemProps extends SuiStyleType, SuiHTMLAttributes { | "defender" | "branda" | "beehive" + | "danger" /** * Function to be called when the MenuItem is clicked. * @@ -66,6 +78,15 @@ interface DropdownMenuItemProps extends SuiStyleType, SuiHTMLAttributes { * Specifies where the linked document should be opened when the user clicks on the hyperlink. */ target?: "_blank" | "_self" | "_parent" | "_top" | string + /** + * Used in "select-checkbox" mode + */ + isChecked?: boolean + + variable?: ReactNode | string // Content to display as the variable for the dropdown menu item. + description?: string // Content to display as the description for the dropdown menu item. + + _checkboxProps?: CheckboxProps } /** @@ -92,11 +113,15 @@ interface DropdownMenuGroupProps interface DropdownMenuBaseProps extends SuiStyleType { id: string | number // Unique identifier for the dropdown menu item. label: React.ReactNode | string // Content to display as the label for the dropdown menu item. + variable?: string // Content to display as the variable for the dropdown menu item. + description?: string // Content to display as the description for the dropdown menu item. } // Props for an individual item within the dropdown menu. interface MenuItemProps extends DropdownMenuBaseProps { - props: Omit // Additional props for the underlying MenuItem component. + props?: Omit & { + _checkboxProps?: CheckboxProps // Extend _checkboxProps here + } // Additional props for the underlying MenuItem component. } // Props for a group of dropdown menu items. @@ -104,6 +129,10 @@ interface MenuGroupProps extends DropdownMenuBaseProps { menus: Array // An array of MenuItemProps representing the items in the group. } +type getOptionOptTypes = { + page?: number +} + /** * Represents the properties for a dropdown component. */ @@ -114,6 +143,7 @@ interface DropdownProps "className" >, SuiStyleType { + type?: "" | "default" | "select" | "select-checkbox" | "select-variable" /** * The label for the dropdown. */ @@ -153,7 +183,16 @@ interface DropdownProps /** * On click on Menu Item */ - onMenuClick?(id: string | number, e?: React.ChangeEvent): void + onMenuClick?( + option: Record, + e?: React.ChangeEvent, + ): void + /** + * Detect dropdown state (open or closed) + * + * @param isOpen + */ + onToggle?(isOpen: boolean): void /** * Dropdown popover direction */ @@ -174,6 +213,51 @@ interface DropdownProps * whther to hide the label of the button or not */ isResponsive?: boolean + /** + * Display in full width + */ + isFluid?: boolean + /** + * Close dropdown on outer click + */ + closeOnOuterClick?: boolean + /** + * Allow search + */ + allowSearch?: boolean + /** + * Callback function for search + * + * @param {string} query + */ + onSearch?: (query: string) => void + /** + * When options are going to be loaded from API, pass true + */ + isAsync?: boolean + /** + * Useful when isAsync option is enabled + */ + asyncOptions?: { + perPage: number + totalItems?: number + } + /** + * Callback for loading options from API + */ + getOptions?: ( + query: string, + opt: getOptionOptTypes, + options?: DropdownProps["menu"], + ) => Promise + /** + * Menu custom width + */ + menuCustomWidth?: number + /** + * Custom search placeholder + */ + searchPlaceholder?: string } // Type definition for the modal handling functions diff --git a/packages/ui/dropdown/stories/dropdown.stories.tsx b/packages/ui/dropdown/stories/dropdown.stories.tsx index d6c4d6046..05217f573 100644 --- a/packages/ui/dropdown/stories/dropdown.stories.tsx +++ b/packages/ui/dropdown/stories/dropdown.stories.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useState } from "react" // Import required component(s) import { Dropdown as SuiDropdown } from "../src" @@ -8,6 +8,7 @@ import { IconsName } from "@wpmudev/sui-icons" // Import documentation main page import docs from "./ReactDropdown.mdx" +import { MenuItemProps, MenuGroupProps } from "../src/dropdown.types" // Configure default options export default { @@ -21,8 +22,99 @@ export default { }, } +const menuOptions: Array = [ + { + id: "menu-beehive", + label: "Beehive", + props: { + href: "#", + icon: "PluginBeehive", + variation: "beehive", + }, + }, + { + id: "menu-branda", + label: "Branda", + props: { + icon: "PluginBranda", + variation: "branda", + }, + }, + { + id: "menu-defender", + label: "Defender", + props: { + icon: "PluginDefender", + variation: "defender", + }, + }, + { + id: "menu-forminator", + label: "Forminator", + props: { + icon: "PluginForminator", + variation: "forminator", + }, + }, + { + id: "menu-hummingbird", + label: "Hummingbird", + props: { + icon: "PluginHummingbird", + variation: "hummingbird", + }, + }, + { + id: "menu-hustle", + label: "Hustle", + props: { + icon: "PluginHustle", + variation: "hustle", + }, + }, + { + id: "menu-shipper", + label: "Shipper", + props: { + icon: "PluginShipper", + variation: "shipper", + }, + }, + { + id: "menu-smush", + label: "Smush", + props: { + icon: "PluginSmush", + variation: "smush", + }, + }, + { + id: "menu-smartcrawl", + label: "SmartCrawl", + props: { + icon: "PluginSmartcrawl", + variation: "smartcrawl", + }, + }, + { + id: "menu-ivt", + label: "IVT", + props: { + icon: "PluginIvt", + variation: "ivt", + }, + }, +] + // Build story -export const Dropdown = ({ color, ...props }: { color: string }) => { +export const Dropdown = ({ + example, + color, + ...props +}: { + example: string + color: string +}) => { const boxStyle = { display: "flex", gap: "8px", @@ -33,163 +125,185 @@ export const Dropdown = ({ color, ...props }: { color: string }) => { background: "white" === color ? "#333" : "#fff", } + const [checkedItems, setCheckedItems] = useState>([]) + const [optionsAPILimit, setOptionsAPILimit] = useState(0) + return (
- { + const checkedList = [...checkedItems] + + if (checkedList.indexOf(menuId) > -1) { + checkedList.splice(checkedItems.indexOf(menuId), 1) + } else { + checkedList.push(menuId) + } + + setCheckedItems(checkedList as []) + }} + type="select-checkbox" + menu={[ + { + id: "view-form", + label: "View form", + props: { + _checkboxProps: { + isChecked: checkedItems.indexOf("view-form") > -1, }, }, - { - id: "menu-smartcrawl", - label: "SmartCrawl", - props: { - icon: "PluginSmartcrawl", - variation: "smartcrawl", + }, + { + id: "edit-form", + label: "Edit form", + props: { + _checkboxProps: { + isChecked: checkedItems.indexOf("edit-form") > -1, }, }, - { - id: "menu-ivt", - label: "IVT", - props: { - icon: "PluginIvt", - variation: "ivt", + }, + { + id: "duplicate-form", + label: "Duplicate form", + props: { + _checkboxProps: { + isChecked: checkedItems.indexOf("duplicate-form") > -1, }, }, - ], - }, - { - id: "group-2", - label: "Web Services", - menus: [ - { - id: "domain", - label: "DomainName Delight", - props: {}, + }, + { + id: "delete-form", + label: "Delete form", + props: { + variation: "danger", + icon: "Trash", + isDisabled: true, }, - { - id: "cms", - label: "CMS Creation Platter", - props: {}, + }, + ]} + onSearch={(string) => { + // console.log("search", string) + }} + /> + )} + {"select" === example && ( + { + // calculate how many items to skip + const skip = 1 === page ? 0 : page * perPage + // store all menu items here + const options: any = [] + + const baseAPI = `https://dummyjson.com/products/search` + + // fetch data from API + await fetch( + `${baseAPI}?limit=${perPage}&skip=${skip}&q=${search}`, + ) + .then((res) => res.json()) + .then((result) => { + // set total numbers of options + if (optionsAPILimit === 0) { + setOptionsAPILimit(result?.total) + } + + result.products.forEach((item: any) => { + options.push({ id: item?.id, label: item?.title }) + }) + }) + + return options + }} + _style={{ + width: "250px", + }} + /> + )} + {"select-variable" === example && ( + -
{ + // console.log("search", string) }} - > - -
-
-
- -
- CUSTOM CONTENT ONLY -
-
+ /> + )}*/} + {"custom" === example && ( + +
+ +
+
+ )} + {"pro" === example && ( + + )}
@@ -197,6 +311,7 @@ export const Dropdown = ({ color, ...props }: { color: string }) => { } Dropdown.args = { + example: "pro", label: "Menu Button", isSmall: false, isFixedHeight: true, @@ -204,10 +319,34 @@ Dropdown.args = { renderContentOnTop: false, placement: "right", buttonIcon: "Menu", + allowSearch: true, + closeOnOuterClick: true, + isAsync: false, onMenuClick: () => {}, } Dropdown.argTypes = { + example: { + name: "Type", + options: [ + // "select-checkbox", + // "select", + // "select-variable", + "pro", + "custom", + ], + control: { + type: "select", + labels: { + // "select-checkbox": "Example: Select + Checkbox", + // select: "Example: Dropdown", + // "select-variable": "Example: Select + Variable", + pro: "Example: Pro Menu", + icon: "Example: Icon Only", + custom: "Example: Custom Content", + }, + }, + }, label: { name: "Label", control: "text", @@ -233,10 +372,6 @@ Dropdown.argTypes = { options: IconsName, control: "select", }, - onMenuClick: { - name: "On Click", - type: Function, - }, placement: { name: "Placement", options: ["left", "right"], @@ -248,6 +383,27 @@ Dropdown.argTypes = { }, }, }, + isFluid: { + name: "Full width", + control: "boolean", + }, + allowSearch: { + name: "Search", + control: "boolean", + }, + onMenuClick: { + name: "onClick", + type: Function, + }, + closeOnOuterClick: { + name: "Close (Outer click)", + control: "boolean", + }, + isAsync: { table: { disable: true } }, isResponsive: { table: { disable: true } }, _htmlProps: { table: { disable: true } }, + _style: { table: { disable: true } }, + type: { table: { disable: true } }, + colorScheme: { table: { disable: true } }, + asyncOptions: { table: { disable: true } }, } diff --git a/packages/ui/input/src/input.tsx b/packages/ui/input/src/input.tsx index 59c7ccecd..078cda4f7 100644 --- a/packages/ui/input/src/input.tsx +++ b/packages/ui/input/src/input.tsx @@ -26,304 +26,302 @@ import { InputProps } from "./input.types" import { Tooltip } from "@wpmudev/sui-tooltip" // Build input component -const Input: ForwardRefExoticComponent> = - forwardRef( - ( - { - type = "text", - defaultValue = "", - placeholder, - hint, - id, - className, - inputClass, - isMultiLine = false, - isSmall, - isReadOnly = false, - isError = false, - isDisabled = false, - onClickIcon, - onClick, - onFocus, - onKeyDown, - onMouseEnter, - onMouseLeave, - onMouseDownCapture, - onMouseUp, - onMouseUpCapture, - onBlur, - onBlurCapture, - onChange, - onClear, - icon, - iconPosition, - iconHint = "", - iconTooltipWidth, - allowClear = false, - disableInteractions = false, - isRequired = false, // - pattern, - onKeyUp, - customWidth, - validate, - validateOnMount, - resetValidation, - _htmlProps = {}, - _style = {}, - }, - ref, - ) => { - // Generate an id for the input if it's not provided - const uniqueId = useId() - - if (!id) { - id = uniqueId - } +const Input = forwardRef( + ( + { + type = "text", + defaultValue = "", + placeholder, + hint, + id, + className, + inputClass, + isMultiLine = false, + isSmall, + isReadOnly = false, + isError = false, + isDisabled = false, + onClickIcon, + onClick, + onFocus, + onKeyDown, + onMouseEnter, + onMouseLeave, + onMouseDownCapture, + onMouseUp, + onMouseUpCapture, + onBlur, + onBlurCapture, + onChange, + onClear, + icon, + iconPosition, + iconHint = "", + iconSize, + iconTooltipWidth, + allowClear = false, + disableInteractions = false, + isRequired = false, // + pattern, + onKeyUp, + customWidth, + validate, + validateOnMount, + resetValidation, + _htmlProps = {}, + _style = {}, + }, + ref, + ) => { + // Generate an id for the input if it's not provided + const uniqueId = useId() - // Define states - const [value, setValue] = - useState(defaultValue) - const [isHovered, isFocused, interactionMethods] = useInteraction({ - onMouseEnter, - onMouseLeave, - onMouseDownCapture, - onMouseUp, - onMouseUpCapture, - onBlur, - onBlurCapture, - }) + if (!id) { + id = uniqueId + } - const [hasError, setHasError] = useState(false) + // Define states + const [value, setValue] = useState(defaultValue) + const [isHovered, isFocused, interactionMethods] = useInteraction({ + onMouseEnter, + onMouseLeave, + onMouseDownCapture, + onMouseUp, + onMouseUpCapture, + onBlur, + onBlurCapture, + }) + const [hasError, setHasError] = useState(false) - // Properties validation - const hasID = !isUndefined(id) && !isEmpty(id) + // Properties validation + const hasID = !isUndefined(id) && !isEmpty(id) - if (!hasID) { - throw new Error( - `Empty parameter is not valid. More details below:\n\n⬇️ ⬇️ ⬇️\n\n📦 Shared UI - Components: Input\n\nThe parameter "id" in the "Input" component is required.\n\n`, - ) - } - - useEffect(() => { - setValue(defaultValue) - }, [defaultValue]) + if (!hasID) { + throw new Error( + `Empty parameter is not valid. More details below:\n\n⬇️ ⬇️ ⬇️\n\n📦 Shared UI - Components: Input\n\nThe parameter "id" in the "Input" component is required.\n\n`, + ) + } - // handle on change - const handleChange = useCallback( - (e: React.ChangeEvent) => { - // update value if input isn't read-only - if (!isReadOnly) { - setValue((e?.target?.value ?? "") as InputProps["defaultValue"]) - } + useEffect(() => { + setValue(defaultValue) + }, [defaultValue]) - if (!!onChange) { - onChange(e) - } - }, - [isReadOnly, onChange], - ) + // handle on change + const handleChange = useCallback( + (e: React.ChangeEvent) => { + // update value if input isn't read-only + if (!isReadOnly) { + setValue((e?.target?.value ?? "") as InputProps["defaultValue"]) + } - // Clear input value - const onClearCallback = useCallback(() => { - setValue("" as InputProps["defaultValue"]) - if (!!onClear) { - onClear("") + if (!!onChange) { + onChange(e) } - }, [onClear]) + }, + [isReadOnly, onChange], + ) - // flags - const hasValue = !isUndefined(value) && !isEmpty((value ?? "") as string) - const hasPlaceholder = !isUndefined(placeholder) && !isEmpty(placeholder) - const hasClassInput = !isUndefined(inputClass) && !isEmpty(inputClass) + // Clear input value + const onClearCallback = useCallback(() => { + setValue("" as InputProps["defaultValue"]) + if (!!onClear) { + onClear("") + } + }, [onClear]) - // Define input type - let inputType: string | undefined = "text" + // flags + const hasValue = !isUndefined(value) && !isEmpty((value ?? "") as string) + const hasPlaceholder = !isUndefined(placeholder) && !isEmpty(placeholder) + const hasClassInput = !isUndefined(inputClass) && !isEmpty(inputClass) - // expected types - if (typeValues.includes(type as string)) { - inputType = type - } + // Define input type + let inputType: string | undefined = "text" - const { suiInlineClassname } = useStyles(_style, className ?? "") + // expected types + if (typeValues.includes(type as string)) { + inputType = type + } - // Generate class names based on the prop values - const classNames = generateCN( - "sui-input", - { - sm: isSmall, - readonly: isReadOnly, - hover: isHovered && !isReadOnly, - focus: isFocused && !isReadOnly && !isError, - filled: hasValue, - "has-icon": !isEmpty(icon), - "icon-start": !isEmpty(iconPosition) && "start" === iconPosition, - "icon-end": !isEmpty(iconPosition) && "end" === iconPosition, - error: isError, - disabled: isDisabled, - // Define multiline class name - [`multiline${isSmall ? "-sm" : ""}`]: isMultiLine, - }, - suiInlineClassname, - ) + const { suiInlineClassname } = useStyles(_style, className ?? "") - // Generate input class names - const inputClassNames = generateCN( - "sui-input__input", - { - "allow-clear": - allowClear && !isEmpty(value as string) && !isMultiLine, - }, - hasClassInput ? inputClass : "", - ) + // Generate class names based on the prop values + const classNames = generateCN( + "sui-input", + { + sm: isSmall, + readonly: isReadOnly, + hover: isHovered && !isReadOnly, + focus: isFocused && !isReadOnly && !isError, + filled: hasValue, + "has-icon": !isEmpty(icon), + "icon-start": !isEmpty(iconPosition) && "start" === iconPosition, + "icon-end": !isEmpty(iconPosition) && "end" === iconPosition, + error: isError, + disabled: isDisabled, + // Define multiline class name + [`multiline${isSmall ? "-sm" : ""}`]: isMultiLine, + }, + suiInlineClassname, + ) - // Define main tag - let TagName = "input" + // Generate input class names + const inputClassNames = generateCN( + "sui-input__input", + { + "allow-clear": allowClear && !isEmpty(value as string) && !isMultiLine, + }, + hasClassInput ? inputClass : "", + ) - // render as textarea if multiline requested - if (isMultiLine) { - TagName = "textarea" - } + // Define main tag + let TagName = "input" - const hasHintText = !isEmpty(hint ?? "") + // render as textarea if multiline requested + if (isMultiLine) { + TagName = "textarea" + } - /** - * validate when key up - * - * @param {any} e - */ - const onInputKeyUp = (e: any) => { - if (validate && isFunction(validate)) { - // Validate the input - validate(value) - } + const hasHintText = !isEmpty(hint ?? "") - // Pass data to prop method - if (onKeyUp) { - onKeyUp(e) - } + /** + * validate when key up + * + * @param {any} e + */ + const onInputKeyUp = (e: any) => { + if (validate && isFunction(validate)) { + // Validate the input + validate(value) } + // Pass data to prop method + if (onKeyUp) { + onKeyUp(e) + } + } - // validate on mount if applicable - useEffect(() => { - if (validateOnMount && validate && isFunction(validate)) { - validate(value) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // Input field props - const attrs = { - id, - ref, - type: condContent(!isMultiLine, inputType), - placeholder: condContent(hasPlaceholder, placeholder), - "aria-label": placeholder || "input", - readOnly: condContent(isReadOnly, true), - disabled: condContent(isDisabled, true), - value: value ?? "", - className: inputClassNames, - onChange: handleChange, - // Interaction methods - ...(!!disableInteractions ? {} : interactionMethods), - // Any additional props - required: isRequired, - pattern, - onKeyUp: onInputKeyUp, - onClick, - onFocus, - onKeyDown, + // validate on mount if applicable + useEffect(() => { + // validate on mount if applicableuseEffect(() => { + if (validateOnMount && validate && isFunction(validate)) { + validate(value) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) - /** - * Render icon - */ - const renderIcon = () => { - if (!(icon && !isMultiLine)) { - return null - } + // Input field props + const attrs = { + id, + ref, + type: condContent(!isMultiLine, inputType), + placeholder: condContent(hasPlaceholder, placeholder), + "aria-label": placeholder || "input", + readOnly: condContent(isReadOnly, true), + disabled: condContent(isDisabled, true), + value: value ?? "", + className: inputClassNames, + onChange: handleChange, + // Interaction methods + ...(!!disableInteractions ? {} : interactionMethods), + // Any additional props + required: isRequired, + pattern, + onKeyUp: onInputKeyUp, + onClick, + onFocus, + onKeyDown, + } - if (!isEmpty(iconHint)) { - return ( - { - if (onClickIcon) { - onClickIcon() - } - }} - > - {iconHint} - - ) - } + /** + * Render icon + */ + const renderIcon = () => { + if (!(icon && !isMultiLine)) { + return null + } + if (!isEmpty(iconHint)) { return ( - { + { if (onClickIcon) { - onClickIcon(e) + onClickIcon() } }} - /> + > + {iconHint} + ) } - // Render component return ( + { + if (onClickIcon) { + onClickIcon(e) + } + }} + {...(iconSize && { size: iconSize })} + /> + ) + } + + // Render component + return ( +
+ {"start" === iconPosition && renderIcon()}
- {"start" === iconPosition && renderIcon()} -
- - {hasHintText && ( - - {!isEmpty(value as string) && ( -
{value}
- )} - {hasHintText && ( -
{hint}
- )} -
- )} -
- {"end" === iconPosition && renderIcon()} - {allowClear && !isEmpty(value as string) && !isMultiLine && ( - + + {hasHintText && ( + + {!isEmpty(value as string) && ( +
{value}
+ )} + {hasHintText && ( +
{hint}
+ )} +
)}
- ) - }, - ) + {"end" === iconPosition && renderIcon()} + {allowClear && !isEmpty(value as string) && !isMultiLine && ( + + )} +
+ ) + }, +) Input.displayName = "Input" diff --git a/packages/ui/input/src/input.types.ts b/packages/ui/input/src/input.types.ts index f82c963fd..545eb9aa8 100644 --- a/packages/ui/input/src/input.types.ts +++ b/packages/ui/input/src/input.types.ts @@ -101,7 +101,7 @@ interface InputProps * The callback function for handling input changes. */ onChange?: ( - event: React.ChangeEvent | string, + event: React.ChangeEvent, ) => void /** * When key up in input field @@ -135,6 +135,10 @@ interface InputProps * Optional icon position to be displayed before or after text. */ iconPosition?: "start" | "end" + /** + * Optional icon position to be displayed before or after text. + */ + iconSize?: "sm" | "md" /* * Icon hint */ diff --git a/packages/ui/navigation/src/navigation-user.tsx b/packages/ui/navigation/src/navigation-user.tsx index 8c7dedcad..94f14a361 100644 --- a/packages/ui/navigation/src/navigation-user.tsx +++ b/packages/ui/navigation/src/navigation-user.tsx @@ -63,6 +63,7 @@ const NavigationUser: React.FC = ({ trigger={userAvatarBtn} renderContentOnTop={true} menu={menu ?? []} + menuCustomWidth={240} > {getUserBlock()} diff --git a/packages/ui/navigation/stories/navigation.stories.tsx b/packages/ui/navigation/stories/navigation.stories.tsx index a9965b21a..967ab61ee 100644 --- a/packages/ui/navigation/stories/navigation.stories.tsx +++ b/packages/ui/navigation/stories/navigation.stories.tsx @@ -103,6 +103,7 @@ export const Navigation = (props: { buttonIcon="Bell" label="Connect features" placement="left" + menuCustomWidth={280} menu={[ { id: "group-1", diff --git a/packages/ui/search/src/search.tsx b/packages/ui/search/src/search.tsx index 7ad8159de..9e4c32549 100644 --- a/packages/ui/search/src/search.tsx +++ b/packages/ui/search/src/search.tsx @@ -8,6 +8,7 @@ import { useInteraction, useOuterClick, useStyles } from "@wpmudev/sui-hooks" import { SearchProps } from "./search.types" import { SearchOptions } from "./search-options" +import { Info } from "@wpmudev/sui-icons" // Build "search" component const Search: React.FC = ({ @@ -131,13 +132,19 @@ const Search: React.FC = ({ disableInteractions={true} isDisabled={isDisabled ?? false} placeholder={placeholder ?? ""} - {...inputProps} + _htmlProps={{ + autoComplete: "off", + ..._renderHTMLPropsSafely(inputProps), + }} /> {isPopoverVisible && "smart" === variation && (
{!isFiltered ? ( -
- {searchHint.replace("#number#", searchMinChars)} +
+
+ + {searchHint.replace("#number#", searchMinChars)} +
) : ( { - const props = { + const props: SelectBaseProps = { id: "standard-select", label: "Select", options: [ { - icon: "settings", + icon: "Settings", id: "option-1", label: "Option 1 is the option.", isSelected: false, }, { - icon: "settings", + icon: "Settings", id: "option-2", label: "Option 2", isSelected: false, @@ -78,7 +78,7 @@ describe("@wpmudev/sui-select", () => { fireEvent.click(selectHeader as Element) // Get options elements - const options = select.querySelectorAll(".sui-select__dropdown--option") + const options = select.querySelectorAll(".sui-dropdown__menu-item") const selectAll = options[0] const firstOption = options[1] const secondOption = options[2] diff --git a/packages/ui/select/src/elements/multiselect-search.tsx b/packages/ui/select/src/elements/multiselect-search.tsx index 9960295f1..442130295 100644 --- a/packages/ui/select/src/elements/multiselect-search.tsx +++ b/packages/ui/select/src/elements/multiselect-search.tsx @@ -1,15 +1,12 @@ import React, { HTMLProps, RefObject, useCallback } from "react" import { _renderHTMLPropsSafely, SuiHTMLAttributes } from "@wpmudev/sui-utils" +import { SelectSearchInputProps } from "../select.types" -interface SelectSearchInputProps - extends SuiHTMLAttributes> { - id?: string - onChange?: (event: React.ChangeEvent) => void - ref?: RefObject - placeholder?: string -} - -const Search: React.FC = ({ id, onChange }) => { +const Search: React.FC = ({ + id, + onChange, + _htmlProps, +}) => { // handle on change input const handleInputChange = useCallback( (event: React.ChangeEvent) => { @@ -27,6 +24,7 @@ const Search: React.FC = ({ id, onChange }) => { className="sui-select__search--input" onChange={handleInputChange} autoComplete="off" + {..._renderHTMLPropsSafely(_htmlProps)} /> ) } diff --git a/packages/ui/select/src/elements/select-dropdown.tsx b/packages/ui/select/src/elements/select-dropdown.tsx index 94995c617..4893b6ea7 100644 --- a/packages/ui/select/src/elements/select-dropdown.tsx +++ b/packages/ui/select/src/elements/select-dropdown.tsx @@ -1,27 +1,26 @@ import React, { Fragment, - RefObject, useCallback, KeyboardEvent, MouseEvent, useId, HTMLProps, + ChangeEvent, } from "react" -import { Checkbox } from "@wpmudev/sui-checkbox" -import { Icon } from "./select-icon" -import { Search } from "./multiselect-search" -import { useStylesTypes } from "@wpmudev/sui-hooks" -interface SelectDropdownProps extends useStylesTypes { - options: Record[] - onEvent?: (id: string | number) => void - selectAll?: () => void - isSmall?: boolean - isMultiSelect?: boolean - selected?: Record | string - ref?: RefObject - onKeyDown?(e?: any): void -} +import { InteractionTypes, useInteraction, useStyles } from "@wpmudev/sui-hooks" +import { _renderHTMLPropsSafely, generateCN } from "@wpmudev/sui-utils" +import { Dropdown as SuiDropdown } from "@wpmudev/sui-dropdown" + +import { + SelectDropdownOptionProps, + SelectDropdownProps, + SelectOptionType, +} from "../select.types" +import { + MenuItemProps, + MenuGroupProps, +} from "@wpmudev/sui-dropdown/src/dropdown.types" const Dropdown: React.FC = ({ options, @@ -30,105 +29,127 @@ const Dropdown: React.FC = ({ isSmall = false, isMultiSelect = false, selected = "", + isSearchable = false, + optionAppreance, + dropdownRef = null, + onToggle = (isOpen: boolean) => {}, + onSearch = (value: string) => {}, + onChange, + _htmlProps, + _dropdownProps, + ...props }) => { // generate unique name for checkbox const name = "select-" + useId() + const { suiInlineClassname } = useStyles(props) const onSelect = useCallback( - (e: any, id: string) => { + (e: any, option: SelectOptionType) => { if ((!e.key || (!!e.key && e.key === "Enter")) && onEvent) { - onEvent(id) + onEvent(option) + if (onChange) { + onChange(option) + } } }, - [onEvent], + [onChange, onEvent], ) - const getOptProps = (id: string) => ({ + const getOptProps = (option: SelectOptionType) => ({ ref: undefined, - onClick: (e: MouseEvent) => onSelect(e, id), - onKeyDown: (e?: KeyboardEvent) => onSelect(e, id), + onKeyDown: (e?: KeyboardEvent) => onSelect(e, option), }) + const wrapper = (content: Array) => ( + } + onToggle={onToggle} + isFluid={true} + isFixedHeight={true} + className={generateCN("sui-select__dropdown", {}, suiInlineClassname)} + menu={content} + onMenuClick={(option: SelectOptionType, e: MouseEvent) => { + onSelect(e, option) + }} + {...(isMultiSelect && { + type: "select-checkbox", + onSearch, + allowSearch: true, + })} + _htmlProps={{ + "aria-label": "dropdown-options", + ...getOptProps, + }} + {..._dropdownProps} + > + ) + // Render options for the dropdown - const renderOptions = () => { - // Render regular options - return ( -
    - {options.map( - ({ - icon, - id, - label, - isSelected, - newLabel = label, - boldLabel = "", - }) => ( -
  • )} - > - - {icon && } - - {boldLabel && {boldLabel}} - {newLabel} - - -
  • - ), - )} -
+ const renderOptions = () => + wrapper( + options?.map((option) => { + option = { + ...option, + ...(isSearchable && { + label: ( + + {option?.boldLabel && {option?.boldLabel}} + { + option?.[ + isSearchable && !!option?.boldLabel ? "newLabel" : "label" + ] + } + + ), + }), + props: { + ...option.props, + className: generateCN("", { + "sui-select__dropdown--option": true, + "sui-select__dropdown--selected": option?.isSelected, + }), + }, + } + return option + }) || [], ) - } // Render options for the multiselect dropdown const renderMultiselectOptions = () => { - const allSelected = options.every((option) => option.isSelected) - const isIndeterminate = options.find((option) => option.isSelected) - - return ( - -
- - -
-
    -
  • - -
  • - {options.map(({ id, label, isSelected }) => ( -
  • - onSelect(e, id), - onKeyDown: (e) => onSelect(e, id), - }} - /> -
  • - ))} -
-
- ) + const allSelected = options?.every((option) => option?.isSelected) + const isIndeterminate = options?.find((option) => option?.isSelected) + const newOptions = options + ? [ + { + id: "select-all", + label: "Select all", + props: { + _checkboxProps: { + isChecked: allSelected, + isIndeterminate: !allSelected && !!isIndeterminate, + onChange: selectAll, + isSmall, + }, + }, + }, + ...options.map((option) => { + return { + ...option, + props: { + ...option.props, + _checkboxProps: { + ...option?.props?._checkboxProps, + isChecked: option?.isSelected, + isSmall, + }, + }, + } + }), + ] + : [] + return wrapper(newOptions) } // Render the appropriate dropdown options based on isMultiSelect diff --git a/packages/ui/select/src/elements/select-input.tsx b/packages/ui/select/src/elements/select-input.tsx index 8e6bd7273..e7cf428c4 100644 --- a/packages/ui/select/src/elements/select-input.tsx +++ b/packages/ui/select/src/elements/select-input.tsx @@ -1,7 +1,7 @@ -import React, { useState, useRef, useEffect, useId, LegacyRef } from "react" +import React, { useState, useEffect, useId, LegacyRef } from "react" + import { Input } from "@wpmudev/sui-input" -import { useStyles, useStylesTypes } from "@wpmudev/sui-hooks" -import { generateCN } from "@wpmudev/sui-utils" +import { useStylesTypes } from "@wpmudev/sui-hooks" interface InputWithAutoCompleteProps extends useStylesTypes { id?: string @@ -16,6 +16,7 @@ interface InputWithAutoCompleteProps extends useStylesTypes { onChange?: ( event: React.ChangeEvent, ) => void + onClick?: () => void onValueChange: (val: string) => void onEvent?: (event: React.ChangeEvent) => void ref?: LegacyRef @@ -30,10 +31,10 @@ const InputWithAutoComplete: React.FC = ({ selected = { label: "" }, placeholder, dropdownItems = [], - dropdownToggle, onValueChange = () => {}, onChange = () => {}, onEvent = () => {}, + onClick = () => {}, interactionMethods, }) => { const generatedId = useId() @@ -49,7 +50,9 @@ const InputWithAutoComplete: React.FC = ({ // Filter options list based on the searched value const filteredOptions = React.useMemo(() => { return isFiltered - ? dropdownItems?.filter(({ label }) => label.startsWith(value)) + ? dropdownItems?.filter(({ searchLabel }) => + searchLabel.startsWith(value), + ) : dropdownItems }, [dropdownItems, isFiltered, value]) @@ -86,21 +89,24 @@ const InputWithAutoComplete: React.FC = ({ return ( | undefined, + onKeyDown: onInputKeyDown, + onClick, + ...interactionMethods, + }} /> ) } diff --git a/packages/ui/select/src/elements/select-selected.tsx b/packages/ui/select/src/elements/select-selected.tsx index bf1980b4a..bc89845f2 100644 --- a/packages/ui/select/src/elements/select-selected.tsx +++ b/packages/ui/select/src/elements/select-selected.tsx @@ -14,23 +14,7 @@ import { } from "@wpmudev/sui-utils" import { Icon } from "./select-icon" import { InputWithAutoComplete } from "./select-input" -import { IconsNamesType } from "@wpmudev/sui-icons" - -interface SelectSelectedProps - extends Omit, "selected"> { - id: string - controlRef: HTMLDivElement | HTMLInputElement | null - expanded?: boolean - arrow?: IconsNamesType - selected?: Record | string - selectLabel?: string - isSmall?: boolean - isMultiSelect?: boolean - removeSelection?: (optionId: number | string) => void - dropdownToggle: () => void - clearSelection: () => void - interactionMethods: object -} +import { SelectSelectedProps } from "../select.types" // Build "Select Selected" component. const Selected: React.FC = ({ @@ -118,7 +102,7 @@ const Selected: React.FC = ({ aria-label={selectLabel} aria-haspopup="listbox" aria-expanded={expanded} - >
+ /> {selectedContent} {isMultiSelect && !isUndefined(selected) && @@ -141,7 +125,10 @@ const Selected: React.FC = ({ } interface SelectSelectedSearchProps - extends Omit, "selected" | "ref" | "onChange"> { + extends Omit< + HTMLProps, + "selected" | "ref" | "onChange" | "onClick" + > { arrow?: string isSmall?: boolean controlRef: HTMLDivElement | HTMLInputElement | null @@ -188,13 +175,6 @@ const SelectedSearch: React.FC = ({ interactionMethods={interactionMethods} {...props} /> - {(close || selected?.label) && ( - - )}
) } diff --git a/packages/ui/select/src/index.ts b/packages/ui/select/src/index.ts index 8b18a490f..203600ba9 100644 --- a/packages/ui/select/src/index.ts +++ b/packages/ui/select/src/index.ts @@ -1,6 +1,6 @@ // Import required component(s). -export { Select } from "./variants/select-standard" -export { SearchSelect } from "./variants/select-search" -export { MultiSelect } from "./variants/select-multiselect" +export { Select } from "./select-standard" +export { SelectVariable } from "./select-variable" +export { MultiSelect } from "./select-multiselect" -export type { SelectBaseProps, SelectOptionType } from "./variants/select-base" +export type * from "./select.types" diff --git a/packages/ui/select/src/variants/select-base.tsx b/packages/ui/select/src/select-base.tsx similarity index 59% rename from packages/ui/select/src/variants/select-base.tsx rename to packages/ui/select/src/select-base.tsx index 5117c9091..4958a6cde 100644 --- a/packages/ui/select/src/variants/select-base.tsx +++ b/packages/ui/select/src/select-base.tsx @@ -19,90 +19,25 @@ import { usePrevious, useStyles, } from "@wpmudev/sui-hooks" +import { DropdownRefProps } from "@wpmudev/sui-dropdown" -import { Dropdown } from "../elements/select-dropdown" -import { Selected, SelectedSearch } from "../elements/select-selected" +import { SelectBaseProps, SelectOptionType } from "./select.types" +import { Dropdown } from "./elements/select-dropdown" +import { Selected, SelectedSearch } from "./elements/select-selected" import { SearchDropdown, RemoveAll, SelectAll, RemoveSelection, MultiSelectSearch, -} from "../utils/functions" - -export type SelectOptionType = - | Record - | Record[] - | string - | undefined - -/** - * This interface defines the props for the SelectBase component. - * It extends the Omit utility to remove 'onMouseLeave' and 'onMouseEnter' properties - * from the HTMLProps type. - */ -interface SelectBaseProps - extends Omit< - HTMLProps, - | "onMouseLeave" - | "onMouseEnter" - | "selected" - | "height" - | "content" - | "translate" - | "width" - | "color" - >, - SuiStyleType, - SuiHTMLAttributes { - /** Unique ID */ - id?: string - /** An array of options for the select */ - options?: Record[] - /** Additional CSS class name for styling */ - className?: string - /** Current selected option */ - selected?: Record | string - /** Label for the select component */ - label?: string - /** Whether the select is disabled or not */ - isDisabled?: boolean - /** Whether the select is displayed in a small size */ - isSmall?: boolean - /** Whether the select has an error state */ - isError?: boolean - /** Whether the select allows multiple selections */ - isMultiSelect?: boolean - /** Whether the select has a search functionality */ - isSearchable?: boolean - /** Add a custom width in pixels */ - customWidth?: number - /** - * Event handler for mouse enter event. - * It is of type Pick, which means it selects - * the "onMouseEnter" property from the "InteractionTypes" type. - */ - onMouseEnter?: Pick - /** - * Event handler for mouse leave event. - * It is of type Pick, which means it selects - * the "onMouseLeave" property from the "InteractionTypes" type. - */ - onMouseLeave?: Pick - /** - * Pass selected item to parent component - * - * @param {Record | Record[]} option option or options list - */ - onChange?(option: SelectOptionType): void -} +} from "./utils/functions" const Select: React.FC = ({ id, options, className, selected, - label = "select", + label = "Select option", isDisabled = false, isSmall = false, isError = false, @@ -112,8 +47,10 @@ const Select: React.FC = ({ onMouseLeave = () => null, customWidth, onChange, + optionAppreance, _style = {}, _htmlProps = {}, + _dropdownProps = {}, }) => { const uniqueId = useId() @@ -121,35 +58,30 @@ const Select: React.FC = ({ id = `select-${uniqueId}` } - if (!options) { - options = [ - { id: "option-1", label: "Option 1" }, - { id: "option-2", label: "Option 2" }, - { id: "option-3", label: "Option 3" }, - ] - } - // set ref to dropdown. const ref = useRef(null) const controlRef = useRef(null) + const dropdownRef = useRef(null) const [isDropdownOpen, setIsDropdownOpen] = useState(false) - const [items, setItems] = useState[]>(options) - const [filteredItems, setFilteredItems] = useState(options) + const [items, setItems] = useState(options ?? []) + const [filteredItems, setFilteredItems] = useState( + options ?? [], + ) const [selectedItem, setSelectedItems] = useState< - Record | string | undefined + Record | string | undefined | SelectOptionType >(selected) - // Hide dropdown when click outside of it - useOuterClick(ref, () => { - setIsDropdownOpen(false) - }) - - // update options useEffect(() => { setItems(options ?? []) + setFilteredItems(options ?? []) }, [options]) + // Hide dropdown when click outside of it + useOuterClick(ref, () => { + dropdownRef.current?.close() + }) + // hold isDropdownOpen's previous val const prevIsDropdownOpen = usePrevious(isDropdownOpen) @@ -214,14 +146,14 @@ const Select: React.FC = ({ // Select search function. const handleSearchDropdown = (event: ChangeEvent) => { const searchValue = event.target.value.toLowerCase() - setIsDropdownOpen(true) + dropdownRef.current?.open() SearchDropdown(searchValue, items, setFilteredItems) } // Multiselect search function. - const handleMultiSelectSearch = (event: ChangeEvent) => { - const searchValue = event.target.value.toLowerCase() - setIsDropdownOpen(true) + const handleMultiSelectSearch = (value: string) => { + const searchValue = value + dropdownRef.current?.open() MultiSelectSearch(searchValue, items, setFilteredItems) } @@ -238,26 +170,39 @@ const Select: React.FC = ({ * * @param {string|number|Object} option Option ID or object */ - const updateItem = (option: Record | string | undefined) => { + const updateItem = (option: SelectOptionType | SelectOptionType[]) => { setSelectedItems(option) if (onChange) { onChange(option) } } - const updateSelected = (optionId: number | string) => { + const updateSelected = (optionObj: SelectOptionType) => { + if (!options) { + setSelectedItems(optionObj) + dropdownRef.current?.close() + return + } + const optionIndex = filteredItems.findIndex( - (option) => option.id === optionId, + (option) => option.id === optionObj.id, ) const updatedItems = [...filteredItems] - const isSelected = updatedItems[optionIndex].isSelected + const isSelected = updatedItems[optionIndex]?.isSelected + if (!isMultiSelect) { updatedItems.forEach((option) => (option.isSelected = false)) - updatedItems[optionIndex].isSelected = true + updatedItems[optionIndex] = { + ...updatedItems[optionIndex], + isSelected: true, + } setFilteredItems(updatedItems) - setIsDropdownOpen(false) + dropdownRef.current?.close() } else { - updatedItems[optionIndex].isSelected = !isSelected + updatedItems[optionIndex] = { + ...updatedItems[optionIndex], + isSelected: !isSelected, + } setFilteredItems(updatedItems) } } @@ -270,11 +215,13 @@ const Select: React.FC = ({ selected: selectedItem, selectLabel: label, isSmall, - dropdownToggle: () => setIsDropdownOpen(!isDropdownOpen), clearSelection: () => { RemoveAll(updateItem, items, setFilteredItems) }, ...(!isSearchable && { + dropdownToggle: () => { + dropdownRef.current?.toggle() + }, arrow: isDropdownOpen ? "ChevronUp" : "ChevronDown", }), ...(isSearchable && { @@ -283,11 +230,14 @@ const Select: React.FC = ({ onChange: (e: ChangeEvent) => { handleSearchDropdown(e) updateItem({ - ...(selectedItem as Record), + ...(selectedItem as SelectOptionType), label: e.target.value, }) }, - onEvent: (optionId: number | string) => updateSelected(optionId), + onEvent: (optionId: SelectOptionType) => updateSelected(optionId), + onClick: () => { + dropdownRef.current?.toggle() + }, }), ...(isMultiSelect && { isMultiSelect, @@ -299,18 +249,34 @@ const Select: React.FC = ({ // Dropdown props const dropdownProps = { + optionAppreance, options: filteredItems, selected: selectedItem, isSmall, + onChange, + ...(isSearchable && { + isSearchable, + options: filteredItems.map((option) => ({ + ...option, + searchLabel: option.label, + })), + }), ...(isMultiSelect && { isMultiSelect, + options: filteredItems.map((option) => ({ + ...option, + props: { + _checkboxProps: { isSmall }, + }, + })), selectAll: () => { SelectAll(filteredItems, setFilteredItems) }, - onChange: (e: ChangeEvent) => { - handleMultiSelectSearch(e) + onSearch: (value: string) => { + handleMultiSelectSearch(value) }, }), + _dropdownProps, } // Render component @@ -332,15 +298,17 @@ const Select: React.FC = ({ interactionMethods={interactionMethods} /> )} - {isDropdownOpen && ( - // @ts-ignore - { - updateSelected(optionId) - }} - /> - )} + {/*// @ts-ignore*/} + { + setIsDropdownOpen(isOpen) + }} + onEvent={(optionId: SelectOptionType) => { + updateSelected(optionId) + }} + />
) } diff --git a/packages/ui/select/src/variants/select-multiselect.tsx b/packages/ui/select/src/select-multiselect.tsx similarity index 100% rename from packages/ui/select/src/variants/select-multiselect.tsx rename to packages/ui/select/src/select-multiselect.tsx diff --git a/packages/ui/select/src/variants/select-standard.tsx b/packages/ui/select/src/select-standard.tsx similarity index 100% rename from packages/ui/select/src/variants/select-standard.tsx rename to packages/ui/select/src/select-standard.tsx diff --git a/packages/ui/select/src/select-variable.tsx b/packages/ui/select/src/select-variable.tsx new file mode 100644 index 000000000..b126e8a6d --- /dev/null +++ b/packages/ui/select/src/select-variable.tsx @@ -0,0 +1,23 @@ +import React from "react" + +// Import required component(s). +import { Select as Base, SelectBaseProps } from "./select-base" + +// Build "Multi Select" component. +const SelectVariable: React.FC = ({ + _dropdownProps, + ...props +}) => { + return ( + + ) +} + +// Publish required component(s). +export { SelectVariable } diff --git a/packages/ui/select/src/select.types.ts b/packages/ui/select/src/select.types.ts new file mode 100644 index 000000000..32bb32d96 --- /dev/null +++ b/packages/ui/select/src/select.types.ts @@ -0,0 +1,169 @@ +import React, { HTMLProps, LegacyRef, ReactNode, Ref, RefObject } from "react" +import { SuiHTMLAttributes, SuiStyleType } from "@wpmudev/sui-utils" +import { InteractionTypes, useStylesTypes } from "@wpmudev/sui-hooks" +import { IconsNamesType } from "@wpmudev/sui-icons" +import { DropdownRefProps } from "@wpmudev/sui-dropdown" +import { MenuItemProps } from "@wpmudev/sui-dropdown/src/dropdown.types" + +interface SelectOptionType extends MenuItemProps { + id: string + label: ReactNode | string + icon?: IconsNamesType + isSelected?: boolean + // dynamic internal props + searchLabel?: string + boldLabel?: string + newLabel?: string + isHovered?: boolean + isFocused?: boolean +} + +/** + * This interface defines the props for the SelectBase component. + * It extends the Omit utility to remove 'onMouseLeave' and 'onMouseEnter' properties + * from the HTMLProps type. + */ +interface SelectBaseProps + extends Omit< + HTMLProps, + | "onMouseLeave" + | "onMouseEnter" + | "selected" + | "height" + | "content" + | "translate" + | "width" + | "color" + | "onChange" + >, + SuiStyleType, + SuiHTMLAttributes { + /** Unique ID */ + id?: string + /** An array of options for the select */ + options?: SelectOptionType[] + /** Additional CSS class name for styling */ + className?: string + /** Current selected option */ + selected?: Record | string + /** Label for the select component */ + label?: string + /** Whether the select is disabled or not */ + isDisabled?: boolean + /** Whether the select is displayed in a small size */ + isSmall?: boolean + /** Whether the select has an error state */ + isError?: boolean + /** Whether the select allows multiple selections */ + isMultiSelect?: boolean + /** Whether the select has a search functionality */ + isSearchable?: boolean + /** Add a custom width in pixels */ + customWidth?: number + /** + * Event handler for mouse enter event. + * It is of type Pick, which means it selects + * the "onMouseEnter" property from the "InteractionTypes" type. + */ + onMouseEnter?: Pick + /** + * Event handler for mouse leave event. + * It is of type Pick, which means it selects + * the "onMouseLeave" property from the "InteractionTypes" type. + */ + onMouseLeave?: Pick + /** + * Pass selected item to parent component + * + * @param {Record | Record[]} option option or options list + */ + onChange?(option: SelectOptionType | SelectOptionType[] | string): void + /** + * Use this method to adjust option item + * + * @param { JSX.Element} jsx + * @param {SelectOptionType} option + */ + optionAppreance?(jsx: JSX.Element, option: SelectOptionType): JSX.Element + + _dropdownProps?: object +} + +interface SelectSelectedProps + extends Omit, "selected"> { + id: string + controlRef: HTMLDivElement | HTMLInputElement | null + expanded?: boolean + arrow?: IconsNamesType + selected?: Record | string + selectLabel?: string + isSmall?: boolean + isMultiSelect?: boolean + removeSelection?: (optionId: number | string) => void + dropdownToggle: () => void + clearSelection: () => void + interactionMethods: object +} + +interface SearchInputWithAutoCompleteProps extends useStylesTypes { + id?: string + expanded?: boolean + controlRef: HTMLDivElement | HTMLInputElement | null + isSmall?: boolean + selected?: { + label: string + } + dropdownItems?: Record[] + onChange?: ( + event: React.ChangeEvent, + ) => void + onValueChange: (val: string) => void + onClick?: () => void + onEvent?: (event: React.ChangeEvent) => void + ref?: LegacyRef + interactionMethods: object + _htmlProps?: object +} + +interface SelectSearchInputProps + extends SuiHTMLAttributes> { + id?: string + onChange?: (event: React.ChangeEvent) => void + ref?: RefObject + placeholder?: string +} + +interface SelectDropdownProps extends useStylesTypes, SuiHTMLAttributes { + options: SelectBaseProps["options"] + onEvent?: (option: SelectOptionType) => void + selectAll?: () => void + onToggle: (isOpen: boolean) => void + isSmall?: boolean + isMultiSelect?: boolean + isSearchable?: boolean + selected?: Record | string + ref?: RefObject + onKeyDown?(e?: any): void + onChange?: (option: SelectOptionType) => void + onSearch?: (value: string) => void + optionAppreance: SelectBaseProps["optionAppreance"] + dropdownRef?: Ref + _dropdownProps?: object +} + +interface SelectDropdownOptionProps { + option: SelectOptionType + children: JSX.Element + optionAppreance: SelectBaseProps["optionAppreance"] + [props: string]: any +} + +export type { + SelectBaseProps, + SelectOptionType, + SelectSelectedProps, + SearchInputWithAutoCompleteProps, + SelectSearchInputProps, + SelectDropdownProps, + SelectDropdownOptionProps, +} diff --git a/packages/ui/select/src/utils/functions.ts b/packages/ui/select/src/utils/functions.ts index 085a4f135..487f85653 100644 --- a/packages/ui/select/src/utils/functions.ts +++ b/packages/ui/select/src/utils/functions.ts @@ -1,33 +1,42 @@ import { isEmpty } from "@wpmudev/sui-utils" +import { SelectOptionType } from "../select.types" // Search for standard dropdown. const SearchDropdown = ( searchValue: string, - options: Record[], - setFilterItems: (options: Record[]) => void, + options: SelectOptionType[], + setFilterItems: (options: SelectOptionType[]) => void, ) => { if (isEmpty(searchValue)) { setFilterItems(options) return } - const filteredItems = options.filter((option) => - option.label.toLowerCase().startsWith(searchValue), + const filteredItems = options?.filter((option) => + option?.searchLabel?.toLowerCase().startsWith(searchValue), ) - const formattedItems = filteredItems.map((option) => { - const index = option.label.toLowerCase().indexOf(searchValue) + const formattedItems = filteredItems?.map((option) => { + const searchLabel = option?.searchLabel + if (!searchLabel) { + return { ...option, isSelected: false } + } + + const index = searchLabel.toLowerCase().indexOf(searchValue) if (index === -1) { return { ...option, isSelected: false } } + const newLabel = + searchLabel.substring(0, index) + + searchLabel.substring(index + searchValue.length) + const boldLabel = searchLabel.substring(0, searchValue.length) + return { ...option, isSelected: false, - newLabel: - option.label.substring(0, index) + - option.label.substring(index + searchValue.length), - boldLabel: option.label.substring(0, searchValue.length), + newLabel, + boldLabel, } }) @@ -37,16 +46,18 @@ const SearchDropdown = ( // Search for multiselect options. const MultiSelectSearch = ( searchValue: string, - options: Record[], - setFilterItems: (options: Record[]) => void, + options: SelectOptionType[], + setFilterItems: (options: SelectOptionType[]) => void, ) => { if (isEmpty(searchValue)) { setFilterItems(options) return } - const filteredItems = options.filter((option) => - option.label.toLowerCase().startsWith(searchValue), + const filteredItems = options.filter( + (option) => + "string" === typeof option?.label && + option?.label?.toLowerCase().startsWith(searchValue.toLowerCase()), ) setFilterItems(filteredItems) @@ -54,13 +65,16 @@ const MultiSelectSearch = ( // Remove all selected options. const RemoveAll = ( - setSelectedItem: (options: Record[]) => void, - options: Record[], - setFilterItems: (options: Record[]) => void, + setSelectedItem: (options: SelectOptionType[]) => void, + options: SelectOptionType[], + setFilterItems: (options: SelectOptionType[]) => void, ) => { const updatedOptions = options.map((option) => ({ ...option, isSelected: false, + props: { + ...option.props, + }, })) setSelectedItem([]) setFilterItems(updatedOptions) @@ -69,14 +83,17 @@ const RemoveAll = ( // Remove single option. const RemoveSelection = ( id: string | number, - options: Record[], - setFilterItems: (options: Record[]) => void, + options: SelectOptionType[], + setFilterItems: (options: SelectOptionType[]) => void, ) => { const updatedOptions = options.map((option) => { if (option.id === id) { return { ...option, isSelected: false, + props: { + ...option.props, + }, } } return option @@ -86,13 +103,17 @@ const RemoveSelection = ( // Select all options in dropdown. const SelectAll = ( - options: Record[], - setFilterItems: (options: Record[]) => void, + options: SelectOptionType[], + setFilterItems: (options: SelectOptionType[]) => void, ) => { - const allSelected = options.every((option) => option.isSelected === true) + const allSelected = options.every((option) => option?.isSelected === true) const updatedOptions = options.map((option) => ({ ...option, isSelected: !allSelected, + props: { + ...option.props, + isSelected: !allSelected, + }, })) setFilterItems(updatedOptions) } diff --git a/packages/ui/select/src/variants/select-search.tsx b/packages/ui/select/src/variants/select-search.tsx deleted file mode 100644 index 1167a7af0..000000000 --- a/packages/ui/select/src/variants/select-search.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react" - -// Import required component(s). -import { Select as Base, SelectBaseProps } from "./select-base" - -// Build "Search Select" component. -const SearchSelect: React.FC = ({ ...props }) => { - return -} - -// Publish required component(s). -export { SearchSelect } diff --git a/packages/ui/select/stories/ReactSelect.stories.tsx b/packages/ui/select/stories/ReactSelect.stories.tsx index 46829ad83..eee4ffa76 100644 --- a/packages/ui/select/stories/ReactSelect.stories.tsx +++ b/packages/ui/select/stories/ReactSelect.stories.tsx @@ -1,23 +1,68 @@ -import React from "react" +import React, { useState } from "react" import { FormField } from "@wpmudev/sui-form-field" // Import required component(s). import { Select as StandardSelect, - SearchSelect, MultiSelect, + SelectVariable, SelectBaseProps, + SelectOptionType, } from "../src" // Import documentation main page. import docs from "./ReactSelect.mdx" +const options = [ + { + id: "option-1", + label: "Option 1", + isSelected: false, + }, + { + id: "option-2", + label: "Option 2", + isSelected: false, + }, + { + id: "option-3", + label: "Option 3", + isSelected: false, + }, + { + id: "option-4", + label: "Option 4", + isSelected: false, + }, + { + id: "option-5", + label: "Option 5", + isSelected: false, + }, + { + id: "option-6", + label: "Option 6", + isSelected: false, + }, + { + id: "option-7", + label: "Option 7", + isSelected: false, + }, + { + id: "option-8", + label: "India", + isSelected: false, + }, +] + // Build "Select" story. const Select = ({ example, errorMessage, isSmall, isDisabled, + isSearchable, ...props }: { example: string @@ -32,6 +77,11 @@ const Select = ({ background: "white" === props.color ? "#333" : "#fff", } + const [asyncOptions, setAsyncOptions] = useState([]) + const [optionsAPILimit, setOptionsAPILimit] = useState(0) + + const perPage = 10 + return (
@@ -48,10 +98,21 @@ const Select = ({ ({ + ...option, + ...(isSearchable && { + searchLabel: option.label, + }), + props: { icon: "Settings" }, + }))} /> )} - {"search" === example && ( + {"select-async" === example && ( - + { + // calculate how many items to skip + const skip = page * perPage - 10 + // store all menu items here + const items: SelectBaseProps["options"] = [] + const baseAPI = `https://dummyjson.com/products/search` + let total = 0 + + // fetch data from API + await fetch( + `${baseAPI}?limit=${perPage}&skip=${skip}&total=50&q=${search}`, + ) + .then((res) => res.json()) + .then((result) => { + total = result.total + + result.products.forEach((item: any) => { + items.push({ + id: item?.id, + label: item?.title, + isSelected: false, + }) + }) + }) + + return { + items, + hasMore: [...items, ...prevLoadedItems].length < 100, + } + }, + }} + /> )} {"multi-select" === example && ( @@ -75,6 +180,54 @@ const Select = ({ )} + {"select-variable" === example && ( + + + + )}
@@ -85,72 +238,25 @@ Select.args = { example: "select", id: "id-1", label: "Select", - options: [ - { - icon: "Settings", - id: "option-1", - label: "Option 1 is the option.", - isSelected: false, - }, - { - icon: "Settings", - id: "option-2", - label: "Option 2", - isSelected: false, - }, - { - icon: "Settings", - id: "option-3", - label: "Option 3", - isSelected: false, - }, - { - icon: "Settings", - id: "option-4", - label: "Option 4", - isSelected: false, - }, - { - icon: "Settings", - id: "option-5", - label: "Option 5", - isSelected: false, - }, - { - icon: "Settings", - id: "option-6", - label: "Option 6", - isSelected: false, - }, - { - icon: "Settings", - id: "option-7", - label: "Option 7", - isSelected: false, - }, - { - icon: "Settings", - id: "option-8", - label: "India", - isSelected: false, - }, - ], isError: false, errorMessage: "Error message", isDisabled: false, isSmall: false, + isSearchable: false, + options, } Select.argTypes = { example: { name: "Example", - options: ["select", "multi-select", "search"], + options: ["select", "select-async", "select-variable", "multi-select"], control: { type: "select", labels: { select: "Example: Select", + "select-async": "Example: Select Async", + "select-variable": "Example: Select Variable", "multi-select": "Example: Multiselect", - search: "Example: Search", }, }, }, @@ -178,6 +284,14 @@ Select.argTypes = { isError: { name: "Error", }, + isSearchable: { + name: "Searchable", + control: "boolean", + if: { + arg: "example", + eq: "select", + }, + }, errorMessage: { name: "Error message", control: "text", diff --git a/packages/ui/table/src/table-toolbar-content.tsx b/packages/ui/table/src/table-toolbar-content.tsx index 75c71ace7..817e50e8f 100644 --- a/packages/ui/table/src/table-toolbar-content.tsx +++ b/packages/ui/table/src/table-toolbar-content.tsx @@ -58,9 +58,9 @@ const TableToolbarContent: React.FC = ({ select: ( // @ts-ignore