From ba82d66564e74c1548c99165d567afa36514cc56 Mon Sep 17 00:00:00 2001 From: Lee Chase Date: Wed, 28 Feb 2024 17:03:29 +0000 Subject: [PATCH 1/6] feat: add fluid button set --- .../WithDisplayBox/WithDisplayBox.scss | 71 ++++++++++++ .../templates/WithDisplayBox/index.js | 54 +++++++++ .../src/components/Button/Button.stories.js | 51 ++++++++- .../Button/__story__/fluid-button-set-args.js | 64 +++++++++++ .../src/components/ButtonSet/ButtonSet.tsx | 106 +++++++++++++++++- .../scss/components/button/_button.scss | 99 +++++++++++++++- 6 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 packages/react/.storybook/templates/WithDisplayBox/WithDisplayBox.scss create mode 100644 packages/react/.storybook/templates/WithDisplayBox/index.js create mode 100644 packages/react/src/components/Button/__story__/fluid-button-set-args.js diff --git a/packages/react/.storybook/templates/WithDisplayBox/WithDisplayBox.scss b/packages/react/.storybook/templates/WithDisplayBox/WithDisplayBox.scss new file mode 100644 index 000000000000..b5be7e793c2a --- /dev/null +++ b/packages/react/.storybook/templates/WithDisplayBox/WithDisplayBox.scss @@ -0,0 +1,71 @@ +// +// Copyright IBM Corp. 2024, 2024 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@use '@carbon/react/scss/spacing' as *; +@use '@carbon/react/scss/theme' as *; +@use '@carbon/react/scss/type' as *; +@use '../prefix' as *; + +$block-class: #{$prefix}--display-box; + +$indicator-width: $spacing-02; +$indicator-height: $spacing-04; + +/** + Adds a box indicating the extent of the available space to the displayed component +*/ +.#{$block-class} { + display: inline-block; + color: $text-helper; + margin-block-start: $spacing-09; /* provides space in docs */ + padding-block-start: $spacing-05; /* space for the indicators */ +} + +.#{$block-class}__story { + inline-size: var(--container-width, 400); + max-inline-size: 100%; +} + +.#{$block-class}__indicator { + position: relative; + border-block-end: 1px solid $text-helper; + inline-size: 100%; + inset-block-start: 0; + margin-block: $spacing-05; + white-space: nowrap; + @include type-style('helper-text-01'); +} + +.#{$block-class}__message { + inset-block-end: $spacing-02; +} + +.sb-main-centered .#{$block-class}__message { + inset-inline-start: 50%; + min-inline-size: 100vh; + text-align: center; + transform: translateX(-50%); +} + +.#{$block-class}__indicator--left, +.#{$block-class}__indicator--right { + position: absolute; + block-size: 0; + border-block-start: $indicator-height solid $text-helper; + border-inline-end: $indicator-width solid transparent; + border-inline-start: $indicator-width solid transparent; + inline-size: 0; + inset-block-end: calc(-1 * $indicator-height); +} + +.#{$block-class}__indicator--left { + inset-inline-start: calc(-1 * $indicator-width); +} + +.#{$block-class}__indicator--right { + inset-inline-end: calc(-1 * $indicator-width); +} diff --git a/packages/react/.storybook/templates/WithDisplayBox/index.js b/packages/react/.storybook/templates/WithDisplayBox/index.js new file mode 100644 index 000000000000..70b8df753654 --- /dev/null +++ b/packages/react/.storybook/templates/WithDisplayBox/index.js @@ -0,0 +1,54 @@ +/** + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import cx from 'classnames'; +import { prefix } from '../_prefix'; +import { Form, Slider } from '../../../src'; +import './WithDisplayBox.scss'; + +const blockClass = `${prefix}--display-box`; + +function WithDisplayBox({ children, className, msg }) { + const [width, setWidth] = useState(400); + + return ( +
+
+ setWidth(value)} + labelText="Adjust width of container with component inside." + /> + +
+
+
+ Width available to component (not part of component). +
+
+
+
+ {children} +
+
+ ); +} + +WithDisplayBox.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + msg: PropTypes.node, +}; + +export { WithDisplayBox }; diff --git a/packages/react/src/components/Button/Button.stories.js b/packages/react/src/components/Button/Button.stories.js index 364a5321f6b9..dcdd2d9cb816 100644 --- a/packages/react/src/components/Button/Button.stories.js +++ b/packages/react/src/components/Button/Button.stories.js @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -12,6 +12,12 @@ import { default as Button, ButtonSkeleton } from '../Button'; import ButtonSet from '../ButtonSet'; import mdx from './Button.mdx'; import './button-story.scss'; +import { WithDisplayBox } from '../../../.storybook/templates/WithDisplayBox'; +import { + fluidButtonLabels, + fluidButtonMapping, + fluidButtonOptions, +} from './__story__/fluid-button-set-args'; export default { title: 'Components/Button', @@ -135,3 +141,46 @@ export const Skeleton = () => (
); + +export const SetOfButtonsFluid = { + parameters: { + controls: { + include: ['Fluid Buttons', 'Stacked'], + }, + }, + argTypes: { + 'Fluid Buttons': { + control: { + type: 'select', + labels: fluidButtonLabels, + }, + options: fluidButtonOptions, + mapping: fluidButtonMapping, + }, + Stacked: { + control: { + type: 'boolean', + }, + description: 'Stacked is ignored when the button set is fluid.', + }, + }, + render: ({ stacked, ...rest }) => { + const buttons = rest['Fluid Buttons']; + + if (!buttons || buttons === 0) { + return
Select one or more buttons.
; + } + + return ( + + + {buttons.map(({ label, kind, key }) => ( + + ))} + + + ); + }, +}; diff --git a/packages/react/src/components/Button/__story__/fluid-button-set-args.js b/packages/react/src/components/Button/__story__/fluid-button-set-args.js new file mode 100644 index 000000000000..628b2e9649ae --- /dev/null +++ b/packages/react/src/components/Button/__story__/fluid-button-set-args.js @@ -0,0 +1,64 @@ +/** + * Copyright IBM Corp. 2024, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +const btn = (label, kind, key) => { + return { + label, + kind, + key, + }; +}; + +const primary = btn('Primary', 'primary', 1); +const danger = btn('Danger', 'danger', 2); +const secondary = btn('Secondary', 'secondary', 3); +const secondary2 = btn('Secondary 2', 'secondary 2', 4); +const tertiary = btn('Tertiary', 'tertiary', 5); +const dangerGhost = btn('Danger-ghost', 'danger--ghost', 6); +const ghost = btn('Ghost', 'ghost', 7); + +const fluidButtonSets = [ + { label: 'None', mapping: [] }, + { label: 'One button', mapping: [primary] }, + { label: 'A danger button', mapping: [danger] }, + { label: 'A ghost button', mapping: [ghost] }, + { label: 'Two buttons', mapping: [secondary, primary] }, + { label: 'Two buttons with one ghost', mapping: [ghost, primary] }, + { label: 'Three buttons', mapping: [secondary, secondary2, primary] }, + { + label: 'Three buttons with one ghost', + mapping: [ghost, secondary, primary], + }, + { + label: 'Three buttons with one danger', + mapping: [ghost, secondary, danger], + }, + { + label: 'Four buttons', + mapping: [tertiary, secondary, secondary2, primary], + }, + { + label: 'Four buttons with one ghost', + mapping: [ghost, secondary, secondary2, primary], + }, + { + label: 'Four buttons with danger ghost', + mapping: [dangerGhost, secondary, secondary2, danger], + }, +]; + +export const fluidButtonOptions = fluidButtonSets.map((_, i) => i); + +export const fluidButtonLabels = fluidButtonSets.reduce((acc, val, i) => { + acc[i] = val.label; + return acc; +}, {}); + +export const fluidButtonMapping = fluidButtonSets.reduce((acc, val, i) => { + acc[i] = val.mapping; + return acc; +}, {}); diff --git a/packages/react/src/components/ButtonSet/ButtonSet.tsx b/packages/react/src/components/ButtonSet/ButtonSet.tsx index 6c490c34473b..8cefb32423b6 100644 --- a/packages/react/src/components/ButtonSet/ButtonSet.tsx +++ b/packages/react/src/components/ButtonSet/ButtonSet.tsx @@ -1,17 +1,23 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { usePrefix } from '../../internal/usePrefix'; import { ForwardRefReturn } from '../../types/common'; +import useIsomorphicEffect from '../../internal/useIsomorphicEffect'; export interface ButtonSetProps extends React.HTMLAttributes { + /** + * fluid: button set resize to the size of the container up to a maximum dependant on the + * number of buttons. Overrides `stacked` property. + */ + fluid?: boolean; /** * Specify the button arrangement of the set (vertically stacked or * horizontal) @@ -19,18 +25,102 @@ export interface ButtonSetProps extends React.HTMLAttributes { stacked?: boolean; } +const fluidMultiples = ['none', 'single', 'double', 'triple', 'quadruple']; +const fluidMultiple = (buttonArray) => { + if (buttonArray.length >= fluidMultiples.length) { + // too many buttons + return 'many'; + } else { + return fluidMultiples[buttonArray.length]; + } +}; + +const buttonOrder = (kind) => + ({ + ghost: 1, + 'danger--ghost': 2, + tertiary: 3, + danger: 5, + primary: 6, + }[kind] ?? 4); + const ButtonSet: ForwardRefReturn = React.forwardRef(function ButtonSet( - { children, className, stacked, ...rest }: ButtonSetProps, + { children, className, fluid, stacked, ...rest }: ButtonSetProps, ref: React.Ref ) { const prefix = usePrefix(); + const fluidInnerRef = useRef(null); + const [isStacked, setIsStacked] = useState(false); + const [sortedChildren, setSortedChildren] = useState( + React.Children.toArray(children) + ); + + /** + * Used to determine if the buttons are currently stacked + */ + useIsomorphicEffect(() => { + const checkStacking = () => { + let newIsStacked = stacked || false; + + if (fluidInnerRef && fluidInnerRef.current) { + const computedStyle = window.getComputedStyle(fluidInnerRef.current); + + newIsStacked = + computedStyle?.getPropertyValue?.('--flex-direction') === 'column'; + } + return newIsStacked; + }; + + if (!fluidInnerRef.current) { + return; + } + + setIsStacked(checkStacking()); + + const resizeObserver = new ResizeObserver(() => { + setIsStacked(checkStacking()); + }); + resizeObserver.observe(fluidInnerRef.current); + + return () => resizeObserver.disconnect(); + }, [isStacked, stacked]); + + useEffect(() => { + const newSortedChildren = React.Children.toArray(children); + newSortedChildren.sort( + (a: any, b: any) => + (buttonOrder(a.props.kind || 'primary') - + buttonOrder(b.props.kind || 'primary')) * + (isStacked ? -1 : 1) + ); + setSortedChildren(newSortedChildren); + + // adding sortedChildren to deps causes an infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children, isStacked]); + const buttonSetClasses = classNames(className, `${prefix}--btn-set`, { - [`${prefix}--btn-set--stacked`]: stacked, + [`${prefix}--btn-set--stacked`]: isStacked, + [`${prefix}--btn-set--fluid`]: fluid, }); + return (
- {children} + {fluid ? ( +
+ {sortedChildren} +
+ ) : ( + children + )}
); }); @@ -47,6 +137,12 @@ ButtonSet.propTypes = { */ className: PropTypes.string, + /** + * fluid: button set resize to the size of the container up to a maximum dependant on the + * number of buttons. + */ + fluid: PropTypes.bool, + /** * Specify the button arrangement of the set (vertically stacked or * horizontal) diff --git a/packages/styles/scss/components/button/_button.scss b/packages/styles/scss/components/button/_button.scss index 53dc9a605c4a..21d3d9cd26df 100644 --- a/packages/styles/scss/components/button/_button.scss +++ b/packages/styles/scss/components/button/_button.scss @@ -1,10 +1,11 @@ // -// Copyright IBM Corp. 2016, 2023 +// Copyright IBM Corp. 2016, 2024 // // This source code is licensed under the Apache-2.0 license found in the // LICENSE file in the root directory of this source tree. // +@use 'sass:math'; @use 'tokens' as *; @use 'vars' as *; @use 'mixins' as *; @@ -21,6 +22,98 @@ @use '../../utilities/visually-hidden' as *; @use '../../utilities/layout'; +@mixin button-set-fluid-column() { + --flex-direction: column; + + .#{$prefix}--btn { + flex: initial; + inline-size: 100%; + max-inline-size: none; + } + + .#{$prefix}--btn--ghost { + padding-inline-start: $spacing-05; + } +} + +@mixin button-set-fluid-layout() { + container-name: button-set; + container-type: inline-size; + + .#{$prefix}--btn-set__fluid-inner { + --flex-direction: row; + + display: flex; + flex-direction: var(--flex-direction); + align-items: stretch; + justify-content: flex-end; + inline-size: 100%; + } + + /* NOTE: Container queries are based on this value */ + $min-auto-stack-action-width: convert.to-rem(176px); + + .#{$prefix}--btn-set__fluid-inner .#{$prefix}--btn { + flex: 0 1 25%; + max-inline-size: convert.to-rem(232px); + } + + .#{$prefix}--btn-set__fluid-inner--auto-stack .#{$prefix}--btn { + min-inline-size: $min-auto-stack-action-width; + } + + .#{$prefix}--btn-set__fluid-inner .#{$prefix}--btn--ghost, + .#{$prefix}--btn-set__fluid-inner .#{$prefix}--btn--danger--ghost { + flex: 1 1 25%; + max-inline-size: none; + padding-inline-start: $spacing-07; /* increased padding when inline */ + } + + @container (width <= #{4 * $min-auto-stack-action-width}) { + .#{$prefix}--btn-set__fluid-inner--quadruple .#{$prefix}--btn { + flex: 1 1 25%; + } + + .#{$prefix}--btn-set__fluid-inner--auto-stack.#{$prefix}--btn-set__fluid-inner--quadruple { + @include button-set-fluid-column(); + } + + .#{$prefix}--btn-set__fluid-inner.#{$prefix}--btn-set__fluid-inner--double { + .#{$prefix}--btn { + flex-basis: 50%; + max-inline-size: none; + } + } + } + + @container (width <= #{3 * $min-auto-stack-action-width}) { + .#{$prefix}--btn-set__fluid-inner--triple .#{$prefix}--btn { + flex: 1 1 math.div(100%, 3); + } + + // Note that by 640px(40rem) sidepanel expects 1 row + .#{$prefix}--btn-set__fluid-inner--auto-stack.#{$prefix}--btn-set__fluid-inner--triple { + @include button-set-fluid-column(); + } + + .#{$prefix}--btn-set__fluid-inner--single .#{$prefix}--btn { + flex: 1 1 100%; + max-inline-size: none; + } + } + + @container (width <= #{2 * $min-auto-stack-action-width}) { + .#{$prefix}--btn-set__fluid-inner--double .#{$prefix}--btn { + flex: 1 1 50%; + } + + // Note that by 480px(30rem) side panel expects 1 row + .#{$prefix}--btn-set__fluid-inner--auto-stack.#{$prefix}--btn-set__fluid-inner--double { + @include button-set-fluid-column(); + } + } +} + @mixin button { .#{$prefix}--btn { @include layout.use('size', $min: 'sm', $default: 'lg', $max: '2xl'); @@ -420,4 +513,8 @@ [dir='rtl'] .#{$prefix}--btn-set .#{$prefix}--btn:not(:focus) { box-shadow: convert.to-rem(1px) 0 0 0 $button-separator; } + + .#{$prefix}--btn-set--fluid { + @include button-set-fluid-layout(); + } } From 827bbb0157b3fb28c4e816568e9bd81303d6a72d Mon Sep 17 00:00:00 2001 From: Lee Chase Date: Wed, 28 Feb 2024 17:59:23 +0000 Subject: [PATCH 2/6] fix: scss variable name --- packages/styles/scss/components/button/_button.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/styles/scss/components/button/_button.scss b/packages/styles/scss/components/button/_button.scss index 21d3d9cd26df..ff108ca3ac92 100644 --- a/packages/styles/scss/components/button/_button.scss +++ b/packages/styles/scss/components/button/_button.scss @@ -51,7 +51,7 @@ } /* NOTE: Container queries are based on this value */ - $min-auto-stack-action-width: convert.to-rem(176px); + $min-inline-button-size: convert.to-rem(176px); .#{$prefix}--btn-set__fluid-inner .#{$prefix}--btn { flex: 0 1 25%; @@ -59,7 +59,7 @@ } .#{$prefix}--btn-set__fluid-inner--auto-stack .#{$prefix}--btn { - min-inline-size: $min-auto-stack-action-width; + min-inline-size: $min-inline-button-size; } .#{$prefix}--btn-set__fluid-inner .#{$prefix}--btn--ghost, @@ -69,7 +69,7 @@ padding-inline-start: $spacing-07; /* increased padding when inline */ } - @container (width <= #{4 * $min-auto-stack-action-width}) { + @container (width <= #{4 * $min-inline-button-size}) { .#{$prefix}--btn-set__fluid-inner--quadruple .#{$prefix}--btn { flex: 1 1 25%; } @@ -86,7 +86,7 @@ } } - @container (width <= #{3 * $min-auto-stack-action-width}) { + @container (width <= #{3 * $min-inline-button-size}) { .#{$prefix}--btn-set__fluid-inner--triple .#{$prefix}--btn { flex: 1 1 math.div(100%, 3); } @@ -102,7 +102,7 @@ } } - @container (width <= #{2 * $min-auto-stack-action-width}) { + @container (width <= #{2 * $min-inline-button-size}) { .#{$prefix}--btn-set__fluid-inner--double .#{$prefix}--btn { flex: 1 1 50%; } From ae8932aab3d20f8e29e87c5194925258ac75f076 Mon Sep 17 00:00:00 2001 From: Lee Chase Date: Wed, 28 Feb 2024 18:16:07 +0000 Subject: [PATCH 3/6] fix: button set test --- .../react/__tests__/__snapshots__/PublicAPI-test.js.snap | 3 +++ packages/react/src/components/ButtonSet/ButtonSet.tsx | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 75e1c6b38f4b..11797e0d5961 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -409,6 +409,9 @@ Map { "className": Object { "type": "string", }, + "fluid": Object { + "type": "bool", + }, "stacked": Object { "type": "bool", }, diff --git a/packages/react/src/components/ButtonSet/ButtonSet.tsx b/packages/react/src/components/ButtonSet/ButtonSet.tsx index 8cefb32423b6..7cfd9e23ee92 100644 --- a/packages/react/src/components/ButtonSet/ButtonSet.tsx +++ b/packages/react/src/components/ButtonSet/ButtonSet.tsx @@ -72,12 +72,13 @@ const ButtonSet: ForwardRefReturn = return newIsStacked; }; + /* initial value not dependant on observer */ + setIsStacked(checkStacking()); + if (!fluidInnerRef.current) { return; } - setIsStacked(checkStacking()); - const resizeObserver = new ResizeObserver(() => { setIsStacked(checkStacking()); }); From 502bbd8582fc551a87de6bf13bdefc9daf249d55 Mon Sep 17 00:00:00 2001 From: Lee Chase Date: Thu, 29 Feb 2024 11:14:54 +0000 Subject: [PATCH 4/6] fix: prevent display box overflowing the story area --- .../.storybook/templates/WithDisplayBox/WithDisplayBox.scss | 1 + packages/react/.storybook/templates/WithDisplayBox/index.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/.storybook/templates/WithDisplayBox/WithDisplayBox.scss b/packages/react/.storybook/templates/WithDisplayBox/WithDisplayBox.scss index b5be7e793c2a..f437796d13a2 100644 --- a/packages/react/.storybook/templates/WithDisplayBox/WithDisplayBox.scss +++ b/packages/react/.storybook/templates/WithDisplayBox/WithDisplayBox.scss @@ -22,6 +22,7 @@ $indicator-height: $spacing-04; display: inline-block; color: $text-helper; margin-block-start: $spacing-09; /* provides space in docs */ + max-inline-size: 100%; padding-block-start: $spacing-05; /* space for the indicators */ } diff --git a/packages/react/.storybook/templates/WithDisplayBox/index.js b/packages/react/.storybook/templates/WithDisplayBox/index.js index 70b8df753654..f8dcf2a0bbfe 100644 --- a/packages/react/.storybook/templates/WithDisplayBox/index.js +++ b/packages/react/.storybook/templates/WithDisplayBox/index.js @@ -28,7 +28,7 @@ function WithDisplayBox({ children, className, msg }) { step={5} value={width} onChange={({ value }) => setWidth(value)} - labelText="Adjust width of container with component inside." + labelText="Adjust maximum width of container in which the component is displayed." />
From a662071416996578e85788cd43129dd3a3140d13 Mon Sep 17 00:00:00 2001 From: Lee Chase Date: Thu, 2 May 2024 10:52:15 +0100 Subject: [PATCH 5/6] fix: remove stacked from story --- packages/react/src/components/Button/Button.stories.js | 10 ++-------- packages/react/src/components/ButtonSet/ButtonSet.tsx | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/react/src/components/Button/Button.stories.js b/packages/react/src/components/Button/Button.stories.js index dcdd2d9cb816..0550468aa93d 100644 --- a/packages/react/src/components/Button/Button.stories.js +++ b/packages/react/src/components/Button/Button.stories.js @@ -157,14 +157,8 @@ export const SetOfButtonsFluid = { options: fluidButtonOptions, mapping: fluidButtonMapping, }, - Stacked: { - control: { - type: 'boolean', - }, - description: 'Stacked is ignored when the button set is fluid.', - }, }, - render: ({ stacked, ...rest }) => { + render: ({ ...rest }) => { const buttons = rest['Fluid Buttons']; if (!buttons || buttons === 0) { @@ -173,7 +167,7 @@ export const SetOfButtonsFluid = { return ( - + {buttons.map(({ label, kind, key }) => (