diff --git a/packages/react-components/src/App.css b/packages/react-components/src/App.css index c9051874..d1ee4385 100644 --- a/packages/react-components/src/App.css +++ b/packages/react-components/src/App.css @@ -31,10 +31,14 @@ main { display: flex; flex-direction: row; gap: var(--layout-margin-medium); + min-width: 0; + max-width: 100%; } .col { display: flex; flex-direction: column; + max-width: 100%; + width: 100%; } .menu { display: flex; diff --git a/packages/react-components/src/components/Select/Select.css b/packages/react-components/src/components/Select/Select.css index 582ab4dd..bddc60ed 100644 --- a/packages/react-components/src/components/Select/Select.css +++ b/packages/react-components/src/components/Select/Select.css @@ -1,55 +1,106 @@ .bcds-react-aria-Select { display: flex; + flex: 1 1 0; flex-direction: column; - align-items: flex-start; + align-items: stretch; + min-width: 0; /* Hacks for `stretch`: https://caniuse.com/mdn-css_properties_max-width_stretch */ max-width: -moz-available; max-width: -webkit-fill-available; } -/* Label above select input */ -.bcds-react-aria-Select--Label { - color: var(--typography-color-secondary); - font: var(--typography-regular-small-body); - padding: var(--layout-padding-xsmall) var(--layout-padding-none); +/* Scroll mode: single-select text truncation */ +.bcds-react-aria-Select.scroll .bcds-react-aria-SelectValue { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } -.bcds-react-aria-Select[data-disabled] > .bcds-react-aria-Select--Label { - color: var(--typography-color-disabled); + +/* Scroll mode: horizontally scrollable tag overlay. + overflow-x: auto forces overflow-y to also clip (per CSS spec), which + would clip tag focus rings. padding-block creates room for the outline + inside the scroll container's padding box, and matching negative + margin-block cancels it out so the overlay's outer box stays the same + size, preserving alignment with wrap mode. */ +.bcds-react-aria-Select.scroll .bcds-react-aria-Select--TagOverlay { + overflow-x: auto; + pointer-events: auto; + scrollbar-width: none; + padding-block: calc( + var(--layout-border-width-medium) + var(--layout-margin-hair) + 1px + ); + margin-block: calc( + -1 * (var(--layout-border-width-medium) + var(--layout-margin-hair) + 1px) + ); + padding-inline: calc( + var(--layout-border-width-medium) + var(--layout-margin-hair) + 1px + ); + margin-inline: calc( + -1 * (var(--layout-border-width-medium) + var(--layout-margin-hair) + 1px) + ); +} +.bcds-react-aria-Select.scroll + .bcds-react-aria-Select--TagOverlay::-webkit-scrollbar { + display: none; } -/* Text description below select input */ -.bcds-react-aria-Select--Description { - font: var(--typography-regular-small-body); - color: var(--typography-color-secondary); - padding: var(--layout-padding-xsmall) var(--layout-padding-none); +/* Scroll mode: prevent tag list from wrapping */ +.bcds-react-aria-Select.scroll .bcds-react-aria-TagList { + flex-wrap: nowrap; + width: max-content; } -/* Error message */ -.bcds-react-aria-Select--Error { - font: var(--typography-regular-small-body); - color: var(--typography-color-danger); +/* Scroll mode: prevent tags from shrinking or wrapping */ +.bcds-react-aria-Select.scroll .bcds-react-aria-Tag { + flex-shrink: 0; +} +.bcds-react-aria-Select.scroll .bcds-react-aria-Tag--Label { + white-space: nowrap; } -/* Error icon */ -.bcds-react-aria-Select[data-invalid] - > .bcds-react-aria-Select--Button - > svg:not(:last-child) { - color: var(--icons-color-danger); +/* Sizing */ +.bcds-react-aria-Select.small .bcds-react-aria-Select--Button { + min-height: 32px; +} +.bcds-react-aria-Select--ListBox.small .bcds-react-aria-Select--ListBoxItem { + padding: var(--layout-padding-xsmall); +} +.bcds-react-aria-Select.small .bcds-react-aria-SelectValue--Text, +.bcds-react-aria-Select--ListBox.small + .bcds-react-aria-Select--ListBoxItem-Text-label { + font: var(--typography-regular-small-body); +} +.bcds-react-aria-Select.medium .bcds-react-aria-Select--Button { + min-height: 40px; +} +.bcds-react-aria-Select--ListBox.medium .bcds-react-aria-Select--ListBoxItem { + padding: var(--layout-padding-small); +} +.bcds-react-aria-Select.medium .bcds-react-aria-SelectValue--Text, +.bcds-react-aria-Select--ListBox.medium + .bcds-react-aria-Select--ListBoxItem-Text-label { + font: var(--typography-regular-body); } /* Select input equivalent */ .bcds-react-aria-Select--Button { background-color: var(--surface-color-forms-default); - border: 1px solid var(--surface-color-border-default); + border: var(--layout-border-width-small) solid + var(--surface-color-border-default); border-radius: var(--layout-border-radius-medium); - cursor: pointer; + box-sizing: border-box; display: flex; - justify-content: space-between; gap: var(--layout-margin-small); align-items: center; - padding: 0 12px; + padding: var(--layout-padding-small); + width: 100%; + min-width: 0; max-width: 100%; } +.bcds-react-aria-Select--Button[data-hovered] { + border-color: var(--surface-color-border-dark); + cursor: pointer; +} .bcds-react-aria-Select--Button.invalid { border-color: var(--support-border-color-danger); } @@ -60,9 +111,6 @@ background-color: var(--surface-color-forms-disabled); cursor: not-allowed; } -.bcds-react-aria-Select--Button[data-hovered] { - border-color: var(--surface-color-border-dark); -} .bcds-react-aria-Select--Button[data-focused] { border-color: var(--surface-color-border-active); outline: solid var(--layout-border-width-medium) @@ -72,27 +120,82 @@ .bcds-react-aria-Select--Button[data-pressed] { border-color: var(--surface-color-border-active); } -.bcds-react-aria-Select--Button.medium { - height: 40px; -} -.bcds-react-aria-Select--Button.small { - height: 32px; -} .bcds-react-aria-Select--Button > .bcds-react-aria-SelectValue { - font: var(--typography-regular-body); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + flex: 1 1 auto; + overflow: visible; + white-space: normal; + text-align: left; min-width: 0; } .bcds-react-aria-Select--Button > svg { - flex-shrink: 0; + flex: 0 0 auto; +} + +/* Overlay for tags in multi-select mode */ +.bcds-react-aria-Select--InputContainer { + display: grid; + width: 100%; + min-width: 0; +} +.bcds-react-aria-Select--InputContainer > .bcds-react-aria-Select--Button { + grid-area: 1 / 1; + align-self: stretch; +} +.bcds-react-aria-Select--TagOverlayContainer { + grid-area: 1 / 1; + min-width: 0; + display: flex; + align-items: center; + padding: var(--layout-padding-small); + padding-right: 2.5rem; + pointer-events: none; +} +.bcds-react-aria-Select--TagOverlay { + width: 100%; + min-width: 0; + pointer-events: none; +} +.bcds-react-aria-Select--TagOverlay .bcds-react-aria-Tag, +.bcds-react-aria-Select--TagOverlay .bcds-react-aria-Tag [slot="remove"], +.bcds-react-aria-Select--TagOverlay .bcds-react-aria-Tag button { + pointer-events: auto; +} + +/* Label above select input */ +.bcds-react-aria-Select--Label { + color: var(--typography-color-secondary); + font: var(--typography-regular-small-body); + padding: var(--layout-padding-xsmall) var(--layout-padding-none); +} +.bcds-react-aria-Select[data-disabled] > .bcds-react-aria-Select--Label { + color: var(--typography-color-disabled); +} + +/* Text description below select input */ +.bcds-react-aria-Select--Description { + font: var(--typography-regular-small-body); + color: var(--typography-color-secondary); + padding: var(--layout-padding-xsmall) var(--layout-padding-none); +} + +/* Error message */ +.bcds-react-aria-Select--Error { + font: var(--typography-regular-small-body); + color: var(--typography-color-danger); +} + +/* Error icon */ +.bcds-react-aria-Select[data-invalid] + .bcds-react-aria-Select--Button + svg:not(:last-child) { + color: var(--icons-color-danger); } /* Dropdown menu panel */ .bcds-react-aria-Select--Popover { background-color: var(--surface-color-forms-default); - border: 1px solid var(--surface-color-border-default); + border: var(--layout-border-width-small) solid + var(--surface-color-border-default); border-radius: var(--layout-border-radius-medium); box-shadow: var(--surface-shadow-medium); box-sizing: border-box; @@ -106,9 +209,10 @@ outline: none; } .bcds-react-aria-Select--ListBox > .bcds-react-aria-Section:not(:first-child) { - border-top: 1px solid var(--surface-color-border-default); + border-top: var(--layout-border-width-small) solid + var(--surface-color-border-default); margin-top: 1px; - padding-top: 2px; + padding-top: var(--layout-padding-hair); } /* Header label within Section of multi-section Select */ @@ -126,7 +230,6 @@ flex-direction: row; align-items: center; gap: var(--layout-margin-small); - padding: var(--layout-padding-small); } .bcds-react-aria-Select--ListBoxItem[data-focused], .bcds-react-aria-Select--ListBoxItem[data-hovered] { @@ -145,10 +248,8 @@ color: var(--typography-color-primary); display: flex; flex-direction: column; - flex-grow: 1; -} -.bcds-react-aria-Select--ListBoxItem-Text-label { - font: var(--typography-regular-body); + flex: 1 1 auto; + min-width: 0; } .bcds-react-aria-Select--ListBoxItem.destructive { color: var(--surface-color-primary-danger-button-default); diff --git a/packages/react-components/src/components/Select/Select.tsx b/packages/react-components/src/components/Select/Select.tsx index 398569bd..6a993da9 100644 --- a/packages/react-components/src/components/Select/Select.tsx +++ b/packages/react-components/src/components/Select/Select.tsx @@ -1,5 +1,4 @@ import { - Button, Collection, FieldError, Header, @@ -16,10 +15,17 @@ import { Text, ValidationResult, } from "react-aria-components"; +import { useState } from "react"; +import { createPortal } from "react-dom"; +import Button from "../Button"; import SvgExclamationIcon from "../Icons/SvgExclamationIcon"; +import SvgCheckIcon from "../Icons/SvgCheckIcon"; import SvgChevronUpIcon from "../Icons/SvgChevronUpIcon"; import SvgChevronDownIcon from "../Icons/SvgChevronDownIcon"; +import TagGroup from "../TagGroup/TagGroup"; +import TagList from "../TagList/TagList"; +import { TagProps } from "../Tag"; import "./Select.css"; @@ -34,6 +40,10 @@ export interface ListBoxItemProps extends ReactAriaListBoxItemProps { iconLeft?: React.ReactElement; /** Right icon slot */ iconRight?: React.ReactElement; + /** Optional color token forwarded to selected tags */ + color?: TagProps["color"]; + /** Optional style token forwarded to selected tags */ + tagStyle?: TagProps["tagStyle"]; } export interface SelectionSectionProps { @@ -44,7 +54,10 @@ export interface SelectionSectionProps { items: ListBoxItemProps[]; } -export interface SelectProps extends ReactAriaSelectProps { +export interface SelectProps< + T extends object, + M extends "single" | "multiple" = "single", +> extends ReactAriaSelectProps { /** Use `items` for a flat list of options */ items?: ListBoxItemProps[]; /** Use `sections` for a sectioned list with `items` options in each section */ @@ -59,10 +72,15 @@ export interface SelectProps extends ReactAriaSelectProps { description?: string; /** Used for data validation and error handling */ errorMessage?: string | ((validation: ValidationResult) => string); + /** Overflow behaviour */ + overflow?: "wrap" | "scroll"; } /** Select displays a collapsible list of options and allows a user to select one of them. */ -export default function Select({ +export default function Select< + T extends object, + M extends "single" | "multiple" = "single", +>({ items, sections, label, @@ -70,33 +88,151 @@ export default function Select({ placeholder, size = "medium", errorMessage, + selectionMode, + overflow = "wrap", ...props -}: SelectProps) { +}: SelectProps) { + const [tagOverlay, setTagOverlay] = useState(null); + return ( - - {({ isOpen, isRequired, isInvalid }) => ( + + {({ isOpen, isRequired, isInvalid, isDisabled }) => ( <> {label && ( )} - + {selectionMode === "multiple" ? ( +
+ +
+
+ ) : ( + + )} {description && ( ({ ({ }`} textValue={item.label} > - {item?.iconLeft && ( -
- {item.iconLeft} -
- )} -
- - {item.label} - - {item.description && ( - - {item.description} - - )} -
- {item?.iconRight && ( -
- {item.iconRight} -
+ {({ isSelected }) => ( + <> + {item?.iconLeft && ( +
+ {item.iconLeft} +
+ )} +
+ + {item.label} + + {item.description && ( + + {item.description} + + )} +
+ {isSelected ? ( +
+ +
+ ) : ( + item?.iconRight && ( +
+ {item.iconRight} +
+ ) + )} + )} )} diff --git a/packages/react-components/src/components/Tag/Tag.css b/packages/react-components/src/components/Tag/Tag.css index e665a1fe..85068752 100644 --- a/packages/react-components/src/components/Tag/Tag.css +++ b/packages/react-components/src/components/Tag/Tag.css @@ -3,9 +3,18 @@ cursor: pointer; border: var(--layout-border-width-small) solid; display: flex; + flex: 0 1 auto; align-items: center; + text-align: left; font: var(--typography-regular-label); width: fit-content; + max-width: 100%; + min-width: 0; +} +.bcds-react-aria-Tag--Label { + min-width: 0; + overflow-wrap: break-word; + white-space: normal; } .bcds-react-aria-Tag .react-aria-Button { background: none; @@ -13,6 +22,7 @@ color: var(--typography-color-primary); cursor: pointer; display: flex; + flex: 0 0 auto; margin: 0; padding: 0; } @@ -69,17 +79,21 @@ } /* Tag size */ -.bcds-react-aria-Tag.medium { - height: var(--layout-padding-xlarge); - padding: var(--layout-padding-hair) var(--layout-padding-medium); - gap: var(--layout-margin-medium); +.bcds-react-aria-Tag.xsmall { + min-height: var(--layout-padding-medium); + padding: var(--layout-padding-hair) var(--layout-padding-small); + gap: var(--layout-margin-xsmall); } .bcds-react-aria-Tag.small { - height: var(--layout-padding-large); + min-height: var(--layout-padding-large); padding: var(--layout-padding-hair) var(--layout-padding-small); gap: var(--layout-margin-small); } - +.bcds-react-aria-Tag.medium { + min-height: var(--layout-padding-xlarge); + padding: var(--layout-padding-hair) var(--layout-padding-medium); + gap: var(--layout-margin-medium); +} /* Selected */ .bcds-react-aria-Tag[data-selected] { border-radius: var(--layout-border-radius-small); diff --git a/packages/react-components/src/components/Tag/Tag.tsx b/packages/react-components/src/components/Tag/Tag.tsx index c5f25bc2..d75d5db2 100644 --- a/packages/react-components/src/components/Tag/Tag.tsx +++ b/packages/react-components/src/components/Tag/Tag.tsx @@ -32,7 +32,7 @@ export interface TagProps extends ReactAriaTagProps { /** * size */ - size?: "small" | "medium"; + size?: "xsmall" | "small" | "medium"; } export default function Tag({ @@ -42,17 +42,19 @@ export default function Tag({ icon, id, textValue, + ...props }: TagProps) { return ( {({ allowsRemoving, isDisabled }: TagRenderProps) => ( <> {icon} - {textValue} + {textValue} {!isDisabled && allowsRemoving && ( , + }, + { + id: "4", + label: "Option 4", + }, + { + id: "5", + label: "Option 5", + }, + { + id: "6", + label: "Option 6", + }, + { + id: "7", + label: "Option 7", + }, + { + id: "8", + label: "Option 8", + }, + ]; + return ( +
+ +
+ ); +} diff --git a/packages/react-components/src/pages/Select/Select.tsx b/packages/react-components/src/pages/Select/Select.tsx index 0e9c2f6e..562de845 100644 --- a/packages/react-components/src/pages/Select/Select.tsx +++ b/packages/react-components/src/pages/Select/Select.tsx @@ -1,14 +1,76 @@ +import { Select } from "@/components"; import UseStateExample from "./UseStateExample"; +import MultiSelectExample from "./MultiSelect"; export default function SelectPage() { + const items = [ + { + id: "1", + label: "Lorem ipsum dolor sit amet, consectetur adipiscing elit", + }, + { + id: "2", + label: + "Suspendisse mi leo, gravida non consectetur vel, tincidunt eu nisl", + }, + { + id: "3", + label: + "Nunc faucibus, magna nec condimentum venenatis, nunc dui euismod metus, et vehicula elit purus in ex", + }, + { + id: "4", + label: + "Quisque velit tortor, facilisis eu orci vitae, tristique convallis nisi. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae", + }, + ]; return ( <>

Select

+

Default size

+
+ +
+

Small size

+
+ +
-
- -

Select with useState

- +
+
+

Multi-select

+ +
+
+

Select with useState

+ +
+
); } diff --git a/packages/react-components/src/pages/Select/UseStateExample.tsx b/packages/react-components/src/pages/Select/UseStateExample.tsx index 5217ce93..0d8f7681 100644 --- a/packages/react-components/src/pages/Select/UseStateExample.tsx +++ b/packages/react-components/src/pages/Select/UseStateExample.tsx @@ -29,12 +29,12 @@ export default function UseStateExample() { return ( <>