From c132b2614faffad69e585cdedf48a1974857e14f Mon Sep 17 00:00:00 2001 From: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:04:52 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9E=A1=20Updates=20Input=20Option=20(#2365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create Menu.styles * installs descendants in menu * extract useMenuHeight * init descendants * pass onItemFocus from provider * abstract out useUpdatedChildren * creates useHighlightReducer * cleanup reducer * skip disabled elements * implement descendant in submenu * Update yarn.lock * rm focus-visible styles we always want focus * fix menu item list style * fix ts errors * rm deprecated hooks * rm debug text * restructure test suite * Create blue-crews-hope.md * Updates stories * adds controlled story * modernizes spec file * Update Menu.stories.tsx * Update SplitButton.spec.tsx * update split button pkg.json * Update yarn.lock * Delete getNewIndex.ts * add // prettier-ignore * mv HighlightReducer Update getUpdatedIndex.ts * Update .gitignore * creates AriaLabelPropsWithChildren type * uses AriaLabelPropsWithChildren in InputOption * Create InputOptionContent generated story * InputOptionContent use tokens, extend className * inputOptionThemeStyles use color tokens * Update titleClassName * create & use InputOptionContext * refactor inputOptionStyles * fix inputoption icon placement & sizing * update icon hover styles * Update Avatar props (#2352) * avatar accepts null text * update generated stories * changeset * Update spotty-ghosts-play.md * add turbo to clean (#2361) * pr * Update .gitignore * create Menu.styles * installs descendants in menu * extract useMenuHeight * init descendants * pass onItemFocus from provider * abstract out useUpdatedChildren * creates useHighlightReducer * cleanup reducer * skip disabled elements * implement descendant in submenu * Update yarn.lock * rm focus-visible styles we always want focus * fix menu item list style * fix ts errors * rm deprecated hooks * rm debug text * restructure test suite * Create blue-crews-hope.md * Updates stories * adds controlled story * modernizes spec file * Update Menu.stories.tsx * adds preserveIconSpace. Update unique classnames * update component exports * Create big-wasps-fix.md * Create shaggy-cheetahs-ring.md * Update big-wasps-fix.md * Renames selected -> checked * creates separate InputOptionContent.stories * Update big-wasps-fix.md * update active wedge to border.primary * include darkMode in InputOptionContext * rm old highlight reducer * rm unused descendant vars * rm checked styles * Update big-wasps-fix.md * typo * revert wedge color to blue.base * revert icon height to default * use disabled prop on `Description` * add style changes to changeset * updates text highlight color targeting * revert implementing of Label component * add description to highlight story * update changesets * add tests for AriaLabelPropsWithChildren * update documentation --------- Co-authored-by: Shaneeza --- .changeset/big-wasps-fix.md | 35 ++++ .changeset/shaggy-cheetahs-ring.md | 5 + packages/a11y/src/A11y.spec.tsx | 86 ++++++-- packages/a11y/src/AriaLabelProps.ts | 12 ++ packages/a11y/src/index.ts | 7 +- .../src/InputOption/InputOption.stories.tsx | 30 ++- .../src/InputOption/InputOption.style.ts | 196 ++++++++---------- .../src/InputOption/InputOption.tsx | 75 ++++--- .../src/InputOption/InputOption.types.ts | 33 ++- .../input-option/src/InputOption/index.ts | 2 +- .../InputOptionContent.stories.tsx | 122 +++++++++++ .../InputOptionContent.styles.ts | 130 +++++++++--- .../InputOptionContent/InputOptionContent.tsx | 68 ++++-- .../InputOptionContent.types.ts | 14 +- .../src/InputOptionContent/index.ts | 7 +- .../input-option/src/InputOptionContext.ts | 15 ++ packages/input-option/src/index.ts | 5 +- 17 files changed, 609 insertions(+), 233 deletions(-) create mode 100644 .changeset/big-wasps-fix.md create mode 100644 .changeset/shaggy-cheetahs-ring.md create mode 100644 packages/input-option/src/InputOptionContent/InputOptionContent.stories.tsx create mode 100644 packages/input-option/src/InputOptionContext.ts diff --git a/.changeset/big-wasps-fix.md b/.changeset/big-wasps-fix.md new file mode 100644 index 0000000000..a87e0c96f7 --- /dev/null +++ b/.changeset/big-wasps-fix.md @@ -0,0 +1,35 @@ +--- +'@leafygreen-ui/input-option': major +--- + +### API changes +- Renames `selected` prop to `checked` (this is done to avoid confusion with the `aria-selected` attribute, which is conditionally applied via the `highlighted` prop) + - `checked` applies the `aria-checked` attribute + - Note: `checked` _does not_ apply any styles. Any "checked" styles must be applied by the consuming component (this is consistent with previous behavior) +- Adds `preserveIconSpace` prop to `InputOptionContent` to determine whether menu items should preserve space for a left glyph, or left align all text content. Use this prop in menus where some items may or may not have icons/glyphs, in order to keep text across menu items aligned. +- Extends `AriaLabelPropsWithChildren` in `InputOptionProps` + - [`AriaLabelPropsWithChildren`](../packages/a11y/src/AriaLabelProps.ts) allows a component to accept any of `aria-label`, `aria-labelledby` or `children` as sufficient text for screen-reader accessibility + +### Styling changes + +- Updates `InputOption` and `InputOptionContent` styles to use updated `color` and `spacing` tokens +- Exports `inputOptionClassName`, and `inputOptionContentClassName`. + +#### Spacing overview + - block padding: 8px + - inline padding: 12px + - icon/text/chevron gap: 8px + - label & description font-size: 13px + - label & description line-height: 16px + +#### Colors overview + - Left & right icon color: `color.[theme].icon.primary` tokens + - Label & Description: use default `Label` & `Description` colors from `typography` + - Background uses `color[theme].background.primary` tokens (including hover & focus states) + - Wedge uses `palette.blue.base` for all modes + - The `highlight` prop uses the `.focus` state color for Icon, Text & Background colors + + +### Internal updates + +- Establishes internal `InputOptionContext` to track `disabled`, `highlighted`, & `checked` attributes. diff --git a/.changeset/shaggy-cheetahs-ring.md b/.changeset/shaggy-cheetahs-ring.md new file mode 100644 index 0000000000..7bfa300522 --- /dev/null +++ b/.changeset/shaggy-cheetahs-ring.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/a11y': minor +--- + +Adds `AriaLabelPropsWithChildren` type that requires either `children`, or other `aria-label` attributes to be defined. Allows a component to accept any of `aria-label`, `aria-labelledby` or `children` as sufficient text for screen-reader accessibility diff --git a/packages/a11y/src/A11y.spec.tsx b/packages/a11y/src/A11y.spec.tsx index 020ec39814..2f31c60006 100644 --- a/packages/a11y/src/A11y.spec.tsx +++ b/packages/a11y/src/A11y.spec.tsx @@ -4,7 +4,11 @@ import { axe } from 'jest-axe'; import { renderHook } from '@leafygreen-ui/testing-lib'; -import { AriaLabelProps, AriaLabelPropsWithLabel } from './AriaLabelProps'; +import { + AriaLabelProps, + AriaLabelPropsWithChildren, + AriaLabelPropsWithLabel, +} from './AriaLabelProps'; import { prefersReducedMotion, useAccessibleForm, @@ -123,50 +127,100 @@ describe('packages/a11y', () => { /* eslint-disable jest/no-disabled-tests, jest/expect-expect, @typescript-eslint/no-unused-vars */ describe.skip('AriaLabelProps types', () => { test('AriaLabelProps', () => { - // @ts-expect-error - const _1: AriaLabelProps = {}; - const _2: AriaLabelProps = { + // @ts-expect-error - empty object not allowed + const empty: AriaLabelProps = {}; + const aria_label: AriaLabelProps = { 'aria-label': 'some label', }; - const _3: AriaLabelProps = { + const labelledby: AriaLabelProps = { 'aria-labelledby': '#some-id', }; - const _4: AriaLabelProps = { + const both_aria: AriaLabelProps = { 'aria-label': 'some label', 'aria-labelledby': '#some-id', }; - [_1, _2, _3, _4]; // Avoid TS error + [empty, aria_label, labelledby, both_aria]; // Avoid TS error }); test('AriaLabelPropsWithLabel', () => { // @ts-expect-error - const _1: AriaLabelPropsWithLabel = {}; - const _2: AriaLabelPropsWithLabel = { + const empty: AriaLabelPropsWithLabel = {}; + const aria_label: AriaLabelPropsWithLabel = { 'aria-label': 'some label', }; - const _3: AriaLabelPropsWithLabel = { + const labelledby: AriaLabelPropsWithLabel = { 'aria-labelledby': '#some-id', }; - const _4: AriaLabelPropsWithLabel = { + const both_aria: AriaLabelPropsWithLabel = { 'aria-label': 'some label', 'aria-labelledby': '#some-id', }; - const _5: AriaLabelPropsWithLabel = { + const label_only: AriaLabelPropsWithLabel = { label: 'some label', }; - const _6: AriaLabelPropsWithLabel = { + const label_and_aria: AriaLabelPropsWithLabel = { label: 'some label', 'aria-label': 'some label', }; - const _7: AriaLabelPropsWithLabel = { + const label_and_labelledby: AriaLabelPropsWithLabel = { label: 'some label', 'aria-labelledby': '#some-id', }; - const _8: AriaLabelPropsWithLabel = { + const all: AriaLabelPropsWithLabel = { label: 'some label', 'aria-label': 'some label', 'aria-labelledby': '#some-id', }; - [_1, _2, _3, _4, _5, _6, _7, _8]; // Avoid TS error + [ + empty, + aria_label, + labelledby, + both_aria, + label_only, + label_and_aria, + label_and_labelledby, + all, + ]; // Avoid TS error + }); + test('AriaLabelPropsWithChildren', () => { + // @ts-expect-error - empty object not allowed + const empty: AriaLabelPropsWithChildren = {}; + const label: AriaLabelPropsWithChildren = { + 'aria-label': 'some label', + }; + const labelledby: AriaLabelPropsWithChildren = { + 'aria-labelledby': '#some-id', + }; + const both_aria: AriaLabelPropsWithChildren = { + 'aria-label': 'some label', + 'aria-labelledby': '#some-id', + }; + const children: AriaLabelPropsWithChildren = { + children: 'some label', + }; + const label_and_children: AriaLabelPropsWithChildren = { + children: 'some label', + 'aria-label': 'some label', + }; + const labelledby_and_children: AriaLabelPropsWithChildren = { + children: 'some label', + 'aria-labelledby': '#some-id', + }; + const all: AriaLabelPropsWithChildren = { + children: 'some label', + 'aria-label': 'some label', + 'aria-labelledby': '#some-id', + }; + + [ + empty, + label, + labelledby, + both_aria, + children, + label_and_children, + labelledby_and_children, + all, + ]; // }); }); }); diff --git a/packages/a11y/src/AriaLabelProps.ts b/packages/a11y/src/AriaLabelProps.ts index 43ddb79243..b2ffe66c24 100644 --- a/packages/a11y/src/AriaLabelProps.ts +++ b/packages/a11y/src/AriaLabelProps.ts @@ -55,3 +55,15 @@ export type AriaLabelPropsWithLabel = */ label: ReactNode; } & Partial); + +/** + * Conditionally requires {@link AriaLabelProps} + * if `children` is not provided + */ +export type AriaLabelPropsWithChildren = + | ({ + children: ReactNode; + } & Partial) + | ({ + children?: ReactNode | undefined; + } & AriaLabelProps); diff --git a/packages/a11y/src/index.ts b/packages/a11y/src/index.ts index 28051d944a..7042b86e0c 100644 --- a/packages/a11y/src/index.ts +++ b/packages/a11y/src/index.ts @@ -1,6 +1,7 @@ -export { - type AriaLabelProps, - type AriaLabelPropsWithLabel, +export type { + AriaLabelProps, + AriaLabelPropsWithChildren, + AriaLabelPropsWithLabel, } from './AriaLabelProps'; export { default as prefersReducedMotion } from './prefersReducedMotion'; export { default as useAccessibleForm } from './useAccessibleForm'; diff --git a/packages/input-option/src/InputOption/InputOption.stories.tsx b/packages/input-option/src/InputOption/InputOption.stories.tsx index 3d4a4839ec..64e962a0aa 100644 --- a/packages/input-option/src/InputOption/InputOption.stories.tsx +++ b/packages/input-option/src/InputOption/InputOption.stories.tsx @@ -1,10 +1,11 @@ +/* eslint-disable react/jsx-key */ import React from 'react'; import { storybookArgTypes, storybookExcludedControlParams, StoryMetaType, } from '@lg-tools/storybook-utils'; -import { StoryFn } from '@storybook/react'; +import { StoryFn, StoryObj } from '@storybook/react'; import Icon, { glyphs } from '@leafygreen-ui/icon'; @@ -15,7 +16,7 @@ import { import { InputOption, type InputOptionProps } from '.'; -const meta: StoryMetaType = { +export default { title: 'Components/InputOption', component: InputOption, parameters: { @@ -31,17 +32,15 @@ const meta: StoryMetaType = { ], }, generate: { + storyNames: ['Generated', 'WithContent'], combineArgs: { darkMode: [false, true], - selected: [false, true], - isInteractive: [false, true], - showWedge: [false, true], - disabled: [false, true], }, }, }, args: { children: 'Some text', + showWedge: true, }, argTypes: { disabled: { @@ -50,7 +49,7 @@ const meta: StoryMetaType = { highlighted: { control: 'boolean', }, - selected: { + checked: { control: 'boolean', }, showWedge: { @@ -69,9 +68,7 @@ const meta: StoryMetaType = { }, as: storybookArgTypes.as, }, -}; - -export default meta; +} satisfies StoryMetaType; export const LiveExample: StoryFn< InputOptionProps & InputOptionContentProps @@ -95,4 +92,15 @@ LiveExample.parameters = { chromatic: { disableSnapshot: true }, }; -export const Generated = () => {}; +export const Generated = { + render: () => <>, + parameters: { + generate: { + combineArgs: { + highlighted: [false, true], + checked: [false, true], + disabled: [false, true], + }, + }, + }, +} satisfies StoryObj; diff --git a/packages/input-option/src/InputOption/InputOption.style.ts b/packages/input-option/src/InputOption/InputOption.style.ts index 5b574949e9..6334fec9ba 100644 --- a/packages/input-option/src/InputOption/InputOption.style.ts +++ b/packages/input-option/src/InputOption/InputOption.style.ts @@ -2,77 +2,98 @@ import { css } from '@leafygreen-ui/emotion'; import { createUniqueClassName, Theme } from '@leafygreen-ui/lib'; import { palette } from '@leafygreen-ui/palette'; import { + color, fontFamilies, + InteractionState, spacing, transitionDuration, typeScales, } from '@leafygreen-ui/tokens'; -export const titleClassName = createUniqueClassName('input-option-title'); -export const descriptionClassName = createUniqueClassName( - 'input-option-description', -); - -export const inputOptionStyles = css` - position: relative; - list-style: none; - outline: none; - border: unset; - margin: 0; - text-align: left; - text-decoration: none; - cursor: pointer; - - font-size: ${typeScales.body1.fontSize}px; - line-height: ${typeScales.body1.lineHeight}px; - font-family: ${fontFamilies.default}; - padding: ${spacing[2]}px ${spacing[2] + spacing[1]}px; - - transition: background-color ${transitionDuration.default}ms ease-in-out; - - &:focus, - &:focus-visible { +import { leftGlyphClassName, titleClassName } from '../InputOptionContent'; + +export const inputOptionClassName = createUniqueClassName('input_option'); + +interface InputOptionStyleArgs { + theme: Theme; + disabled?: boolean; + highlighted?: boolean; + isInteractive?: boolean; +} + +export const getInputOptionStyles = ({ + theme, + disabled, + highlighted, + isInteractive, +}: InputOptionStyleArgs) => { + const ixnState = highlighted + ? InteractionState.Focus + : InteractionState.Default; + return css` + position: relative; + list-style: none; outline: none; border: unset; - } -`; + margin: 0; + text-align: left; + text-decoration: none; + cursor: pointer; -export const titleSelectionStyles = css` - .${titleClassName} { - font-weight: bold; - } -`; + font-size: ${typeScales.body1.fontSize}px; + line-height: ${typeScales.body1.lineHeight}px; + font-family: ${fontFamilies.default}; + padding: ${spacing[200]}px ${spacing[300]}px; -export const inputOptionThemeStyles: Record = { - [Theme.Light]: css` - color: ${palette.black}; - `, - [Theme.Dark]: css` - color: ${palette.gray.light2}; - `, -}; - -export const inputOptionHoverStyles: Record = { - [Theme.Light]: css` - &:hover { - outline: none; - background-color: ${palette.gray.light2}; - } - `, - [Theme.Dark]: css` - &:hover { - outline: none; - background-color: ${palette.gray.dark4}; - } - `, + transition: ${transitionDuration.default}ms ease-in-out; + transition-property: background-color, color; + + color: ${color[theme].text.primary[ixnState]}; + background-color: ${color[theme].background.primary[ixnState]}; + + ${disabled && + css` + cursor: not-allowed; + color: ${color[theme].text.disabled[ixnState]}; + `} + + /* Interactive states */ + ${isInteractive && + !disabled && + css` + /* Hover */ + &:hover { + outline: none; + color: ${color[theme].text.primary.hover}; + background-color: ${color[theme].background.primary.hover}; + + .${titleClassName} { + color: ${color[theme].text.primary.hover}; + } + + .${leftGlyphClassName} { + color: ${color[theme].icon.primary.hover}; + } + } + + /* Focus (majority of styling handled by the 'highlighted' prop) */ + &:focus { + outline: none; + border: unset; + } + `} + `; }; /** in px */ -const wedgeWidth = spacing[1]; +const wedgeWidth = spacing[100]; /** in px */ -const wedgePaddingY = spacing[2]; +const wedgePaddingY = spacing[200]; -export const inputOptionWedge = css` +export const getInputOptionWedge = ({ + disabled, + highlighted, +}: InputOptionStyleArgs) => css` // Left wedge &:before { content: ''; @@ -88,63 +109,16 @@ export const inputOptionWedge = css` transform-origin: 0%; // 0% since we use translateY transition: ${transitionDuration.default}ms ease-in-out; transition-property: transform, background-color; - } -`; - -export const inputOptionActiveStyles: Record = { - [Theme.Light]: css` - outline: none; - background-color: ${palette.blue.light3}; - color: ${palette.blue.dark2}; - &:before { + ${highlighted && + css` transform: scaleY(1) translateY(-50%); background-color: ${palette.blue.base}; - } - `, - [Theme.Dark]: css` - outline: none; - background-color: ${palette.blue.dark3}; - color: ${palette.blue.light3}; + `} - &:before { - transform: scaleY(1) translateY(-50%); - background-color: ${palette.blue.light1}; - } - `, -}; - -export const inputOptionDisabledStyles: Record = { - [Theme.Light]: css` - cursor: not-allowed; - - &, - & .${descriptionClassName} { - color: ${palette.gray.light1}; - } - - &:hover { - background-color: inherit; - } - - &:before { - content: unset; - } - `, - [Theme.Dark]: css` - cursor: not-allowed; - - &, - & .${descriptionClassName} { - color: ${palette.gray.dark1}; - } - - &:hover { - background-color: inherit; - } - - &:before { + ${disabled && + css` content: unset; - } - `, -}; + `} + } +`; diff --git a/packages/input-option/src/InputOption/InputOption.tsx b/packages/input-option/src/InputOption/InputOption.tsx index 100148811c..30bcf6f770 100644 --- a/packages/input-option/src/InputOption/InputOption.tsx +++ b/packages/input-option/src/InputOption/InputOption.tsx @@ -8,14 +8,12 @@ import { usePolymorphic, } from '@leafygreen-ui/polymorphic'; +import { InputOptionContext } from '../InputOptionContext'; + import { - inputOptionActiveStyles, - inputOptionDisabledStyles, - inputOptionHoverStyles, - inputOptionStyles, - inputOptionThemeStyles, - inputOptionWedge, - titleSelectionStyles, + getInputOptionStyles, + getInputOptionWedge, + inputOptionClassName, } from './InputOption.style'; import { InputOptionProps } from './InputOption.types'; @@ -26,7 +24,7 @@ export const InputOption = Polymorphic( children, disabled, highlighted, - selected, + checked, darkMode: darkModeProp, showWedge = true, isInteractive = true, @@ -36,31 +34,46 @@ export const InputOption = Polymorphic( ref, ) => { const { Component } = usePolymorphic(as); - const { theme } = useDarkMode(darkModeProp); + const { theme, darkMode } = useDarkMode(darkModeProp); return ( - - {children} - + + {children} + + ); }, ); diff --git a/packages/input-option/src/InputOption/InputOption.types.ts b/packages/input-option/src/InputOption/InputOption.types.ts index 2d95d7843b..26b97116ff 100644 --- a/packages/input-option/src/InputOption/InputOption.types.ts +++ b/packages/input-option/src/InputOption/InputOption.types.ts @@ -1,6 +1,4 @@ -import { PropsWithChildren } from 'react'; - -import { AriaLabelProps } from '@leafygreen-ui/a11y'; +import { AriaLabelPropsWithChildren } from '@leafygreen-ui/a11y'; import { DarkModeProps } from '@leafygreen-ui/lib'; /** @@ -19,16 +17,29 @@ export interface BaseInputOptionProps { disabled?: boolean; /** - * Defines the currently highlighted option element for keyboard navigation. - * Not to be confused with `selected`, which identifies the currently selected option + * Defines the currently highlighted option, + * and applies the relevant highlight styles and `aria-selected` attribute + * (either ) + * + * Functionally similar to `:focus` state, however `highlight` behaviors are not always implemented with true browser focus state + * (e.g. some components maintain the browser focus on the trigger element, + * and identify the "highlighted" option with only the `aria-selected` attribute). + * + * Not to be confused with `checked`, which identifies the currently active/selected option. * @default false */ highlighted?: boolean; /** - * Whether the component is selected, regardless of keyboard navigation + * Defines the currently selected/active element, regardless of interaction state. + * + * Functionally similar to a checkbox/radio's `checked` attribute, + * this identifies an option as currently selected. + * + * Note: There are no styling changes applied by this prop. + * `Checked` styles must be applied by the implementing component */ - selected?: boolean; + checked?: boolean; /** * Whether a wedge displays on the left side of the item @@ -38,12 +49,12 @@ export interface BaseInputOptionProps { showWedge?: boolean; /** - * Determines whether to show hover, highlight and selected styles + * Determines whether to show hover, highlight and checked styles * @default true */ isInteractive?: boolean; } -export type InputOptionProps = AriaLabelProps & - DarkModeProps & - PropsWithChildren; +export type InputOptionProps = DarkModeProps & + AriaLabelPropsWithChildren & + BaseInputOptionProps; diff --git a/packages/input-option/src/InputOption/index.ts b/packages/input-option/src/InputOption/index.ts index fe97ea657d..1900ff6ad7 100644 --- a/packages/input-option/src/InputOption/index.ts +++ b/packages/input-option/src/InputOption/index.ts @@ -1,5 +1,5 @@ export { InputOption } from './InputOption'; -export { descriptionClassName } from './InputOption.style'; +export { inputOptionClassName } from './InputOption.style'; export type { BaseInputOptionProps, InputOptionProps, diff --git a/packages/input-option/src/InputOptionContent/InputOptionContent.stories.tsx b/packages/input-option/src/InputOptionContent/InputOptionContent.stories.tsx new file mode 100644 index 0000000000..2ac5feb802 --- /dev/null +++ b/packages/input-option/src/InputOptionContent/InputOptionContent.stories.tsx @@ -0,0 +1,122 @@ +/* eslint-disable react/jsx-key */ +import React from 'react'; +import { + InstanceDecorator, + storybookArgTypes, + storybookExcludedControlParams, + StoryMetaType, +} from '@lg-tools/storybook-utils'; +import { StoryObj } from '@storybook/react'; + +import Icon, { glyphs } from '@leafygreen-ui/icon'; + +import { InputOption } from '../InputOption'; + +import { InputOptionContent } from '.'; + +const _withInputOptionDecorator: InstanceDecorator< + typeof InputOption & typeof InputOptionContent +> = (Instance, ctx) => { + const { + args: { darkMode, highlighted, checked, disabled }, + } = ctx || { args: {} }; + return ( + + + + ); +}; + +export default { + title: 'Components/InputOption/Content', + component: InputOptionContent, + parameters: { + default: 'Basic', + controls: { + exclude: [...storybookExcludedControlParams], + }, + generate: { + storyNames: ['Basic', 'Highlighted', 'Checked', 'Disabled'], + combineArgs: { + darkMode: [false, true], + description: [undefined, 'Description'], + preserveIconSpace: [false, true], + leftGlyph: [undefined, ], + rightGlyph: [undefined, ], + }, + excludeCombinations: [ + { + leftGlyph: undefined, + rightGlyph: , + }, + { + preserveIconSpace: false, + leftGlyph: , + }, + ], + decorator: _withInputOptionDecorator, + }, + }, + args: { + children: 'Some text', + showWedge: true, + }, + argTypes: { + disabled: { + control: 'boolean', + }, + highlighted: { + control: 'boolean', + }, + checked: { + control: 'boolean', + }, + showWedge: { + control: 'boolean', + }, + leftGlyph: { + options: Object.keys(glyphs), + control: { type: 'select' }, + }, + rightGlyph: { + options: Object.keys(glyphs), + control: { type: 'select' }, + }, + description: { + control: { type: 'text' }, + }, + as: storybookArgTypes.as, + }, +} satisfies StoryMetaType; + +export const Basic = { + render: () => <>, +} satisfies StoryObj; + +export const Highlighted = { + render: () => <>, + parameters: { + generate: { + args: { + rightGlyph: , + highlighted: true, + }, + }, + }, +} satisfies StoryObj; + +export const Disabled = { + render: () => <>, + parameters: { + generate: { + args: { + disabled: true, + }, + }, + }, +} satisfies StoryObj; diff --git a/packages/input-option/src/InputOptionContent/InputOptionContent.styles.ts b/packages/input-option/src/InputOptionContent/InputOptionContent.styles.ts index 57e09668cb..cf5d6a2d8c 100644 --- a/packages/input-option/src/InputOptionContent/InputOptionContent.styles.ts +++ b/packages/input-option/src/InputOptionContent/InputOptionContent.styles.ts @@ -1,42 +1,112 @@ import { css } from '@leafygreen-ui/emotion'; -import { createUniqueClassName } from '@leafygreen-ui/lib'; -import { spacing } from '@leafygreen-ui/tokens'; +import { createUniqueClassName, Theme } from '@leafygreen-ui/lib'; +import { + color, + spacing, + State, + transitionDuration, + Variant, +} from '@leafygreen-ui/tokens'; +export const inputOptionContentClassName = createUniqueClassName( + 'input_option-content', +); +export const titleClassName = createUniqueClassName('input_option-title'); +export const descriptionClassName = createUniqueClassName( + 'input_option-description', +); export const leftGlyphClassName = createUniqueClassName( - 'input-option-left-glyph', + 'input_option-left-glyph', ); -export const titleBaseStyles = css` - overflow-wrap: anywhere; -`; +export const getContentWrapperStyles = ({ + hasLeftGlyph, + hasRightGlyph, +}: { + hasLeftGlyph: boolean; + hasRightGlyph: boolean; +}) => { + const col1Name = hasLeftGlyph ? 'left-glyph' : 'text'; + const col3Name = hasRightGlyph ? 'right-glyph' : 'text'; -export const descriptionBaseStyles = css` - max-height: ${spacing[3] * 3}px; - overflow: hidden; - font-size: inherit; - text-overflow: ellipsis; -`; + return css` + display: grid; + grid-template-columns: ${spacing[400]}px 1fr ${spacing[400]}px; + grid-template-areas: '${col1Name} text ${col3Name}'; + gap: ${spacing[200]}px; + align-items: center; + width: 100%; + `; +}; -export const contentWrapper = css` - display: grid; - grid-template-columns: ${spacing[3]}px 1fr; - gap: ${spacing[2]}px; - align-items: center; - width: 100%; -`; +interface InputOptionStyleArgs { + theme: Theme; + disabled?: boolean; + highlighted?: boolean; +} -export const textWrapper = css` - grid-column: 2; -`; +export const getLeftGlyphStyles = ({ + theme, + disabled, + highlighted, +}: InputOptionStyleArgs) => { + const variant = disabled ? Variant.Disabled : Variant.Primary; + const interactionState = highlighted ? State.Focus : State.Default; -export const glyphContainer = css` - display: flex; - height: 20px; - align-items: center; + return css` + grid-area: left-glyph; + display: flex; + align-items: center; + // Hover styles set by parent InputOption + color: ${color[theme].icon[variant][interactionState]}; + transition: color ${transitionDuration.default}ms ease-in-out; + `; +}; + +export const getRightGlyphStyles = ({ + theme, + disabled, +}: InputOptionStyleArgs) => { + const variant = disabled ? Variant.Disabled : Variant.Primary; + + return css` + grid-area: right-glyph; + display: flex; + align-items: center; + color: ${color[theme].icon[variant].default}; + transition: color ${transitionDuration.default}ms ease-in-out; + `; +}; + +export const textContainerStyles = css` + grid-area: text; + line-height: ${spacing[400]}px; `; -export const glyphRightStyles = css` - width: ${spacing[3]}px; - grid-column: 3; - grid-row: 1; +export const getTitleStyles = ({ + theme, + highlighted, +}: InputOptionStyleArgs) => css` + overflow-wrap: anywhere; + font-size: inherit; + line-height: inherit; + font-weight: normal; + transition: color ${transitionDuration.default}ms ease-in-out; + + ${highlighted && + css` + font-weight: bold; + color: ${color[theme].text.primary.focus}; + `} `; + +export const getDescriptionStyles = () => { + return css` + max-height: ${spacing[1200]}px; + overflow: hidden; + font-size: inherit; + line-height: inherit; + text-overflow: ellipsis; + transition: color ${transitionDuration.default}ms ease-in-out; + `; +}; diff --git a/packages/input-option/src/InputOptionContent/InputOptionContent.tsx b/packages/input-option/src/InputOptionContent/InputOptionContent.tsx index 758061d235..37640805bd 100644 --- a/packages/input-option/src/InputOptionContent/InputOptionContent.tsx +++ b/packages/input-option/src/InputOptionContent/InputOptionContent.tsx @@ -1,54 +1,90 @@ import React from 'react'; import { cx } from '@leafygreen-ui/emotion'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { Description } from '@leafygreen-ui/typography'; import { descriptionClassName, - titleClassName, -} from '../InputOption/InputOption.style'; -import { - contentWrapper, - descriptionBaseStyles, - glyphContainer, - glyphRightStyles, + getContentWrapperStyles, + getDescriptionStyles, + getLeftGlyphStyles, + getRightGlyphStyles, + getTitleStyles, + inputOptionContentClassName, leftGlyphClassName, - textWrapper, - titleBaseStyles, + textContainerStyles, + titleClassName, } from '../InputOptionContent/InputOptionContent.styles'; +import { useInputOptionContext } from '../InputOptionContext'; import { InputOptionContentProps } from './InputOptionContent.types'; /** * @internal * - * This is a temp workaround to add consistent option styles. Once all components that use an input option are consistent we can add this directly inside `InputOption`. + * This is a temp workaround to add consistent option styles. + * Once all components that use an input option are consistent + * we can add this directly inside `InputOption`. */ export const InputOptionContent = ({ children, description, leftGlyph, rightGlyph, + preserveIconSpace = true, + className, + ...rest }: InputOptionContentProps) => { + const { disabled, highlighted, darkMode } = useInputOptionContext(); + const { theme } = useDarkMode(darkMode); return ( -
+
{leftGlyph && ( -
+
{leftGlyph}
)} -
-
{children}
+
+
+ {children} +
{description && ( {description} )}
{rightGlyph && ( -
{rightGlyph}
+
+ {rightGlyph} +
)}
); diff --git a/packages/input-option/src/InputOptionContent/InputOptionContent.types.ts b/packages/input-option/src/InputOptionContent/InputOptionContent.types.ts index 5e90309b41..9376d446ec 100644 --- a/packages/input-option/src/InputOptionContent/InputOptionContent.types.ts +++ b/packages/input-option/src/InputOptionContent/InputOptionContent.types.ts @@ -1,4 +1,6 @@ -export interface InputOptionContentProps { +import { ComponentProps } from 'react'; + +export interface InputOptionContentProps extends ComponentProps<'div'> { /** * Content to appear inside of option */ @@ -18,4 +20,14 @@ export interface InputOptionContentProps { * Glyph to be displayed to the right of content */ rightGlyph?: React.ReactNode; + + /** + * Preserves space before the text content for a left glyph. + * + * Use in menus where some items may or may not have icons/glyphs, + * in order to keep text across menu items aligned + * + * @default {true} + */ + preserveIconSpace?: boolean; } diff --git a/packages/input-option/src/InputOptionContent/index.ts b/packages/input-option/src/InputOptionContent/index.ts index c969cd0c80..683f850111 100644 --- a/packages/input-option/src/InputOptionContent/index.ts +++ b/packages/input-option/src/InputOptionContent/index.ts @@ -1,3 +1,8 @@ export { InputOptionContent } from './InputOptionContent'; -export { leftGlyphClassName } from './InputOptionContent.styles'; +export { + descriptionClassName, + inputOptionContentClassName, + leftGlyphClassName, + titleClassName, +} from './InputOptionContent.styles'; export { type InputOptionContentProps } from './InputOptionContent.types'; diff --git a/packages/input-option/src/InputOptionContext.ts b/packages/input-option/src/InputOptionContext.ts new file mode 100644 index 0000000000..3cf1d746a7 --- /dev/null +++ b/packages/input-option/src/InputOptionContext.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; + +import { InputOptionProps } from './InputOption'; + +interface InputOptionContextData + extends Pick< + InputOptionProps, + 'checked' | 'darkMode' | 'disabled' | 'highlighted' + > {} + +export const InputOptionContext = createContext( + {} as InputOptionContextData, +); + +export const useInputOptionContext = () => useContext(InputOptionContext); diff --git a/packages/input-option/src/index.ts b/packages/input-option/src/index.ts index 581c09b611..78b2e8c77b 100644 --- a/packages/input-option/src/index.ts +++ b/packages/input-option/src/index.ts @@ -1,11 +1,14 @@ export { type BaseInputOptionProps, - descriptionClassName, InputOption, + inputOptionClassName, type InputOptionProps, } from './InputOption'; export { + descriptionClassName, InputOptionContent, + inputOptionContentClassName, type InputOptionContentProps, leftGlyphClassName, + titleClassName, } from './InputOptionContent';