diff --git a/.storybook/Theme.ts b/.storybook/Theme.ts index 20a08f7e1..d3efc9167 100644 --- a/.storybook/Theme.ts +++ b/.storybook/Theme.ts @@ -1,4 +1,4 @@ -import { create } from '@storybook/theming/create'; +import { create } from '@storybook/theming'; // Docs: https://storybook.js.org/docs/7.6/configure/theming // Keys from https://github.com/storybookjs/storybook/blob/next/code/lib/theming/src/themes/light.ts diff --git a/.storybook/components/DesignTokens/Tier1/Animation.jsx b/.storybook/components/DesignTokens/Tier1/Animation.jsx index c84450e75..65d942fd4 100755 --- a/.storybook/components/DesignTokens/Tier1/Animation.jsx +++ b/.storybook/components/DesignTokens/Tier1/Animation.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export class Tier1Animation extends Component { diff --git a/.storybook/components/DesignTokens/Tier1/Borders.jsx b/.storybook/components/DesignTokens/Tier1/Borders.jsx index dd283e373..215afe07d 100755 --- a/.storybook/components/DesignTokens/Tier1/Borders.jsx +++ b/.storybook/components/DesignTokens/Tier1/Borders.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export class Tier1Borders extends Component { diff --git a/.storybook/components/DesignTokens/Tier1/Colors.tsx b/.storybook/components/DesignTokens/Tier1/Colors.tsx index eaed870e2..8fe4bf82c 100755 --- a/.storybook/components/DesignTokens/Tier1/Colors.tsx +++ b/.storybook/components/DesignTokens/Tier1/Colors.tsx @@ -1,8 +1,8 @@ import React from 'react'; import '../DesignTokens.css'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; import { ColorList } from '../../ColorList/ColorList'; +import Section from '../../Section'; export const Tier1Colors = () => { const getListItems = (filterTerm: string, figmaTokenHeader: string) => diff --git a/.storybook/components/DesignTokens/Tier1/FontFamilies.tsx b/.storybook/components/DesignTokens/Tier1/FontFamilies.tsx index 65d040c8f..31c044190 100644 --- a/.storybook/components/DesignTokens/Tier1/FontFamilies.tsx +++ b/.storybook/components/DesignTokens/Tier1/FontFamilies.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export const FontFamilies = () => ( diff --git a/.storybook/components/DesignTokens/Tier1/FontWeights.tsx b/.storybook/components/DesignTokens/Tier1/FontWeights.tsx index f1e86e0db..291c3746a 100644 --- a/.storybook/components/DesignTokens/Tier1/FontWeights.tsx +++ b/.storybook/components/DesignTokens/Tier1/FontWeights.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export const FontWeights = () => ( diff --git a/.storybook/components/DesignTokens/Tier1/Shadows.jsx b/.storybook/components/DesignTokens/Tier1/Shadows.jsx index 46aba822f..093071d02 100755 --- a/.storybook/components/DesignTokens/Tier1/Shadows.jsx +++ b/.storybook/components/DesignTokens/Tier1/Shadows.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export class Tier1Shadows extends Component { diff --git a/.storybook/components/DesignTokens/Tier1/Sizes.jsx b/.storybook/components/DesignTokens/Tier1/Sizes.jsx index e4a2c58f7..35dddc0da 100755 --- a/.storybook/components/DesignTokens/Tier1/Sizes.jsx +++ b/.storybook/components/DesignTokens/Tier1/Sizes.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export class Tier1Sizes extends Component { diff --git a/.storybook/components/DesignTokens/Tier1/TypographyPresets.jsx b/.storybook/components/DesignTokens/Tier1/TypographyPresets.jsx index 5f918a955..991bb7510 100755 --- a/.storybook/components/DesignTokens/Tier1/TypographyPresets.jsx +++ b/.storybook/components/DesignTokens/Tier1/TypographyPresets.jsx @@ -1,10 +1,10 @@ import { at, forEach } from 'lodash'; import React, { Component } from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import presets from '../../../../src/design-tokens/tier-1-definitions/typography.json'; import flatten from '../../../util/flattenToken'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export class Tier1TypographyPresets extends Component { diff --git a/.storybook/components/DesignTokens/Tier1/TypographyTokens.jsx b/.storybook/components/DesignTokens/Tier1/TypographyTokens.jsx index fbfefa33d..eb742529a 100755 --- a/.storybook/components/DesignTokens/Tier1/TypographyTokens.jsx +++ b/.storybook/components/DesignTokens/Tier1/TypographyTokens.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export class Tier1TypographyTokens extends Component { diff --git a/.storybook/components/DesignTokens/Tier2/Borders.stories.tsx b/.storybook/components/DesignTokens/Tier2/Borders.stories.tsx index 4a1e0fb3a..d2c970928 100755 --- a/.storybook/components/DesignTokens/Tier2/Borders.stories.tsx +++ b/.storybook/components/DesignTokens/Tier2/Borders.stories.tsx @@ -1,8 +1,8 @@ import type { StoryObj } from '@storybook/react'; import React from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export default { diff --git a/.storybook/components/DesignTokens/Tier2/Colors.stories.tsx b/.storybook/components/DesignTokens/Tier2/Colors.stories.tsx index f5acfdd80..3dd335898 100755 --- a/.storybook/components/DesignTokens/Tier2/Colors.stories.tsx +++ b/.storybook/components/DesignTokens/Tier2/Colors.stories.tsx @@ -1,8 +1,8 @@ import type { StoryObj } from '@storybook/react'; import React from 'react'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; import { ColorList } from '../../ColorList/ColorList'; +import Section from '../../Section'; export default { title: 'Design Tokens/Tier 2: Usage/Colors', diff --git a/.storybook/components/DesignTokens/Tier2/Forms.stories.tsx b/.storybook/components/DesignTokens/Tier2/Forms.stories.tsx index ab6467bc8..49a0a0188 100755 --- a/.storybook/components/DesignTokens/Tier2/Forms.stories.tsx +++ b/.storybook/components/DesignTokens/Tier2/Forms.stories.tsx @@ -1,8 +1,8 @@ import type { StoryObj } from '@storybook/react'; import React from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export default { diff --git a/.storybook/components/DesignTokens/Tier2/TypographyUsage.stories.tsx b/.storybook/components/DesignTokens/Tier2/TypographyUsage.stories.tsx index 93fa52d5a..004a3e532 100755 --- a/.storybook/components/DesignTokens/Tier2/TypographyUsage.stories.tsx +++ b/.storybook/components/DesignTokens/Tier2/TypographyUsage.stories.tsx @@ -2,12 +2,12 @@ import type { StoryObj } from '@storybook/react'; import { at, capitalize, forEach } from 'lodash'; import React from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import usages from '../../../../src/design-tokens/tier-2-usage/typography.json'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore importing of a legacy utility file results in some 'any's, which is acceptable for this docs page import flatten from '../../../util/flattenToken'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export default { diff --git a/.storybook/components/DesignTokens/Tier3/Sizes.stories.tsx b/.storybook/components/DesignTokens/Tier3/Sizes.stories.tsx index e73887043..701761683 100644 --- a/.storybook/components/DesignTokens/Tier3/Sizes.stories.tsx +++ b/.storybook/components/DesignTokens/Tier3/Sizes.stories.tsx @@ -1,8 +1,8 @@ import type { StoryObj } from '@storybook/react'; import React from 'react'; -import Grid from '../../../../src/components/Grid'; -import Section from '../../../../src/components/Section'; import filterTokens from '../../../util/filterTokens'; +import Grid from '../../Grid'; +import Section from '../../Section'; import { TokenSpecimen } from '../../TokenSpecimen/TokenSpecimen'; export default { diff --git a/.storybook/components/Docs/GettingStarted.mdx b/.storybook/components/Docs/GettingStarted.mdx index 15c996689..419504770 100644 --- a/.storybook/components/Docs/GettingStarted.mdx +++ b/.storybook/components/Docs/GettingStarted.mdx @@ -19,7 +19,6 @@ The navigation consists of: - **Documentation** are a collection of guidelines and explanations for all the written content on this site. - **Design Tokens** are the reusable CSS variables that can be themed in your application. They are grouped into tiers which describe the specificity of the tokens (with tier one values corresponding to basic definitions, and tier two corresponing to use cases referencing the tier one values, etc.) - **Components** are all the exported offerings of EDS, combined in one list. They consist of various atoms, molecules, and organisms that can be used in a product, along with relevant documentation, implementation examples controls, and tags (describing the component type and status) -- **Pages** are a place to demonstrate layouts with other themes and presets applied. Right now, it houses a layout example using our nascent wireframe theme (used when prototyping). ### Storybook Features diff --git a/.storybook/components/Docs/Guidelines/CodeGuidelines.mdx b/.storybook/components/Docs/Guidelines/CodeGuidelines.mdx index db7064538..415faff3a 100644 --- a/.storybook/components/Docs/Guidelines/CodeGuidelines.mdx +++ b/.storybook/components/Docs/Guidelines/CodeGuidelines.mdx @@ -58,16 +58,17 @@ EDS follows [BEM](http://getbem.com/introduction/) syntax for component class na - **Block** is the primary component block (e.g. `.button`) - **Element** is a child of the primary block (e.g. `.button__text`) -- **Modifier** is a variation of a component style (e.g. `.button--secondary`) +- **Modifier** is a variation of a component style (e.g. `.button--variant-secondary`) -BEM conventions result in an explicit (and yes, sometimes verbose) class string that allows developers to quickly deduce what role any selector plays. +BEM conventions result in an explicit (and yes, sometimes verbose) class string that allows developers to quickly deduce what role any selector plays. One +note about **Modifier** class name parts; by convention in EDS, modifiers should include the property name with the value, for clarity, e.g., `[block]--[propName]-[propValue]` Let's take a look at the following example: -`.button--full-width` +`.button--width-full` - `button` is the block name (“Block” being the “B” in BEM) -- `--full-width` is a modifier, indicating a stylistic variation of the block (“Modifier” being the “M” in BEM) +- `--width-full` is a modifier, indicating a stylistic variation of the block (“Modifier” being the “M” in BEM) Here's another example: @@ -116,7 +117,7 @@ We use focus-visible for most focus states. This ensures that the focus state wi However, we currently support some browser versions that do not support the focus-visible CSS feature, so we also use a fallback block in conjunciton with focus-visible. ```css -.button--primary { +.button--rank-primary { &:focus-visible { @mixin focus; } @@ -132,7 +133,7 @@ However, we currently support some browser versions that do not support the focu #### States and pseudo-selectors ```css -.button--primary { +.button--rank-primary { background: var(--eds-theme-color-background-brand-primary-strong); &:hover, @@ -158,22 +159,22 @@ Use the following conventions: /* button code */ } -.button--secondary { +.button--rank-secondary { /* button--secondary styles */ } -.button--sm { +.button--size-sm { /* button--sm styles */ } .button__text { /* button__text styles */ - .button--secondary & { + .button--rank-secondary & { /* button__text within button--secondary styles */ } - .button--sm & { + .button--size-sm & { /* button__text within button--sm styles */ } } @@ -223,7 +224,7 @@ For example: /** * Full width button. */ -.button--full-width { +.button--width-full { background: red; /* Pushes the button away from the other content because it lives in a flexbox container */ margin-right: auto; @@ -348,7 +349,7 @@ import { Icon } from '../Icon/Icon'; ### Prop Type definitions ```tsx -export interface Props { +export interface ComponentNameProps { /** * Toggles the ability to dismiss the banner via an close button in the top right of the banner */ @@ -427,7 +428,7 @@ export const ComponentName = ({ of, props ...other -}: Props) => { +}: ComponentNameProps) => { ... } ``` @@ -497,7 +498,7 @@ The last thing that appears above the `return` statement is the `componentClassN ```tsx const componentClassName = clsx(styles['my-component'], className, { - [styles['my-component--lg']]: size === 'lg', + [styles['my-component--size-lg']]: size === 'lg', }); ``` @@ -552,7 +553,7 @@ EDS adheres to the following API naming conventions: The default option should be the one most commonly used in order to reduce friction for developers using the components. -- `variant` should be used for primary _stylistic_ variations of a component, such as (e.g. ``). `variant` should be used if there is primarily one variable used to manipulate the component style. +- `variant` should be used for primary _stylistic_ variations of a component, such as (e.g. ``). `variant` should be used if there is primarily one variable used to manipulate the component style. - `size` should be used for adjusting size attributes (e.g. ` + > )} diff --git a/src/components/AppNotification/__snapshots__/AppNotification.test.ts.snap b/src/components/AppNotification/__snapshots__/AppNotification.test.ts.snap index 95db2b2fc..fd20e50eb 100644 --- a/src/components/AppNotification/__snapshots__/AppNotification.test.ts.snap +++ b/src/components/AppNotification/__snapshots__/AppNotification.test.ts.snap @@ -306,7 +306,7 @@ exports[` WithLinkInSubtitle story renders snapshot 1`] = ` Some text with a link diff --git a/src/components/Avatar/Avatar.test.ts b/src/components/Avatar/Avatar.test.ts index 0ade5f735..ed673fc67 100644 --- a/src/components/Avatar/Avatar.test.ts +++ b/src/components/Avatar/Avatar.test.ts @@ -1,9 +1,10 @@ import { generateSnapshots } from '@chanzuckerberg/story-utils'; import { getInitials } from './Avatar'; import * as stories from './Avatar.stories'; +import type { StoryFile } from '../../util/utility-types'; describe('', () => { - generateSnapshots(stories); + generateSnapshots(stories as StoryFile); // Testing handling of surrogate pairs, et al // https://javascript.info/unicode#surrogate-pairs diff --git a/src/components/Badge/Badge.test.tsx b/src/components/Badge/Badge.test.tsx index 09c1843b9..0db84b4fa 100644 --- a/src/components/Badge/Badge.test.tsx +++ b/src/components/Badge/Badge.test.tsx @@ -3,9 +3,10 @@ import { render } from '@testing-library/react'; import React from 'react'; import { Badge } from './Badge'; import * as stories from './Badge.stories'; +import type { StoryFile } from '../../util/utility-types'; describe('', () => { - generateSnapshots(stories); + generateSnapshots(stories as StoryFile); it('throws an error if Badge.Text length is > 3', () => { // expect console error from react, suppressed. diff --git a/src/components/Breadcrumbs/Breadcrumbs.test.tsx b/src/components/Breadcrumbs/Breadcrumbs.test.tsx index 1bc8d8e6e..1ad83a2e8 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.test.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.test.tsx @@ -3,11 +3,12 @@ import { composeStories } from '@storybook/react'; import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import * as stories from './Breadcrumbs.stories'; +import type { StoryFile } from '../../util/utility-types'; const { LongList } = composeStories(stories); describe('', () => { - generateSnapshots(stories); + generateSnapshots(stories as StoryFile); describe('truncation', () => { it('truncates when its content overflows', async () => { diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx index 8516dd340..bee889a9f 100644 --- a/src/components/Button/Button.test.tsx +++ b/src/components/Button/Button.test.tsx @@ -7,6 +7,18 @@ import * as stories from './Button.stories'; import type { StoryFile } from '../../util/utility-types'; describe('); + + expect(consoleWarnMock).toHaveBeenCalledTimes(0); + expect(consoleErrorMock).toHaveBeenCalledTimes(1); + }); + + it('warns when icon-only Button instances contain children', () => { + render( + , + ); + + expect(consoleWarnMock).toHaveBeenCalledTimes(1); + expect(consoleErrorMock).toHaveBeenCalledTimes(0); + }); + }); }); diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 255c29d2f..645840905 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import React, { forwardRef } from 'react'; +import { assertEdsUsage } from '../../util/logging'; import type { Size } from '../../util/variant-types'; import Icon, { type IconName } from '../Icon'; import LoadingIndicator from '../LoadingIndicator'; @@ -132,6 +133,20 @@ export const Button = forwardRef( isLoading && styles['button--is-loading'], ); + assertEdsUsage( + [ + typeof isDisabled === 'undefined' && + typeof other.disabled !== 'undefined', + ], + 'Use "isDisabled" instead of "disabled" on button instances', + 'error', + ); + + assertEdsUsage( + [iconLayout === 'icon-only' && typeof children !== 'undefined'], + 'Specifying content for "children" when using icon-only layout is not required and can be removed.', + ); + return ( ', () => { - generateSnapshots(stories); + generateSnapshots(stories as StoryFile); }); diff --git a/src/components/Icon/__snapshots__/Icon.test.ts.snap b/src/components/Icon/__snapshots__/Icon.test.ts.snap index d4b453fd4..a39c46e6c 100644 --- a/src/components/Icon/__snapshots__/Icon.test.ts.snap +++ b/src/components/Icon/__snapshots__/Icon.test.ts.snap @@ -1593,7 +1593,7 @@ exports[` IconGrid story renders snapshot 1`] = ` xmlns="http://www.w3.org/2000/svg" > = { args: { className: 'w-96', }, + argTypes: { + type: { + control: 'select', + options: [ + 'text', + 'password', + 'datetime-local', + 'date', + 'month', + 'time', + 'week', + 'number', + 'email', + 'url', + 'search', + 'tel', + ], + }, + }, decorators: [(Story) =>
{Story()}
], }; @@ -147,12 +167,62 @@ export const NoVisibleLabel: Story = { }; /** - * Password fields show dots instead of characters, to help with security. + * Password fields show dots instead of characters, to help with security. They allow for show/hide of the field + * contents. */ export const Password: Story = { args: { label: 'Password', type: 'password', + defaultValue: 'secret123', + }, +}; + +/** + * Password fields show dots instead of characters, to help with security. They allow for show/hide of the field + * contents, and resetting. + */ +export const PasswordWithShownText: Story = { + args: { + ...Password.args, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const showHideButton = await canvas.findByRole('button'); + await userEvent.click(showHideButton); + }, +}; + +/** + * You can specify dates of varying details (including full date, month and year, etc.). + * It uses the built-in browser UI to handle date/time input. + */ +export const DateHandling: Story = { + args: { + ...Default.args, + type: 'date', + }, + argTypes: { + type: { + control: 'select', + options: ['datetime-local', 'date', 'month', 'time', 'week'], + }, + }, +}; + +/** + * You can specify time as well, which uses a different internal glyph to trigger the browser UI. + */ +export const TimeHandling: Story = { + args: { + ...Default.args, + type: 'time', + }, + argTypes: { + type: { + control: 'select', + options: ['datetime-local', 'date', 'month', 'time', 'week'], + }, }, }; diff --git a/src/components/InputField/InputField.test.tsx b/src/components/InputField/InputField.test.tsx index 2df5d4c0c..feea37710 100644 --- a/src/components/InputField/InputField.test.tsx +++ b/src/components/InputField/InputField.test.tsx @@ -8,8 +8,11 @@ import { InputField } from './InputField'; import * as stories from './InputField.stories'; import type { StoryFile } from '../../util/utility-types'; +// rest-ing out password stories to avoid incorrect act() warnings after storybook 8 upgrade +const { Password, PasswordWithShownText, ...otherStories } = stories; + describe('', () => { - generateSnapshots(stories as StoryFile); + generateSnapshots(otherStories as StoryFile); it('handles changes to the text within the component', async () => { const user = userEvent.setup(); diff --git a/src/components/InputField/InputField.tsx b/src/components/InputField/InputField.tsx index d63f1fcf3..3b7503b1f 100644 --- a/src/components/InputField/InputField.tsx +++ b/src/components/InputField/InputField.tsx @@ -8,6 +8,7 @@ import type { ForwardedRefComponent, } from '../../util/utility-types'; import type { Status } from '../../util/variant-types'; +import Button from '../Button'; import FieldLabel from '../FieldLabel'; import FieldNote from '../FieldNote'; import Icon, { type IconName } from '../Icon'; @@ -74,7 +75,6 @@ export type InputFieldProps = React.InputHTMLAttributes & { React.InputHTMLAttributes['type'], | 'text' | 'password' - | 'datetime' | 'datetime-local' | 'date' | 'month' @@ -180,6 +180,10 @@ export const InputField: InputFieldType = forwardRef( const shouldRenderOverline = !!(label || required); const [fieldText, setFieldText] = useState(other.defaultValue); + // Handling of behavior when field type is password. Show/hide button + const revealShowHideButton = type === 'password'; + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const overlineClassName = clsx( styles['input-field__overline'], !label && styles['input-field__overline--no-label'], @@ -292,12 +296,24 @@ export const InputField: InputFieldType = forwardRef( ref={ref} required={required} status={shouldRenderError ? 'critical' : status} - type={type} + type={isPasswordVisible ? 'text' : type} {...other} /> - {inputWithin && ( + {(inputWithin || type === 'password') && (
{inputWithin} + {revealShowHideButton && fieldText && ( +
)} {leadingIcon && ( diff --git a/src/components/InputField/__snapshots__/InputField.test.tsx.snap b/src/components/InputField/__snapshots__/InputField.test.tsx.snap index 6171cfe35..7a9b6e29f 100644 --- a/src/components/InputField/__snapshots__/InputField.test.tsx.snap +++ b/src/components/InputField/__snapshots__/InputField.test.tsx.snap @@ -1,5 +1,43 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` DateHandling story renders snapshot 1`] = ` +
+
+
+ +
+
+ +
+
+ This is a fieldnote. +
+
+
+`; + exports[` Default story renders snapshot 1`] = `
InputWithin story renders snapshot 1`] = ` > @@ -156,7 +194,7 @@ exports[` InputWithin story renders snapshot 1`] = `
NoVisibleLabel story renders snapshot 1`] = `
`; -exports[` Password story renders snapshot 1`] = ` -
-
-
- -
-
- -
-
-
-`; - exports[` ReadOnly story renders snapshot 1`] = `
ShowHint story renders snapshot 1`] = ` > @@ -474,7 +481,7 @@ exports[` ShowHint story renders snapshot 1`] = `
@@ -554,6 +561,44 @@ exports[` TabularInput story renders snapshot 1`] = `
`; +exports[` TimeHandling story renders snapshot 1`] = ` +
+
+
+ +
+
+ +
+
+ This is a fieldnote. +
+
+
+`; + exports[` Warning story renders snapshot 1`] = `
WithAMaxLength story renders snapshot 1`] = ` > @@ -644,7 +689,7 @@ exports[` WithAMaxLength story renders snapshot 1`] = ` WithARecommendedLength story renders snapshot 1`] = ` > @@ -693,7 +738,7 @@ exports[` WithARecommendedLength story renders snapshot 1`] = ` WithBothMaxAndRecommendedLength story renders snapshot 1 > @@ -739,10 +784,10 @@ exports[` WithBothMaxAndRecommendedLength story renders snapshot 1 class="input-field__body input-field--has-fieldNote" > WithBothMaxAndRecommendedLength story renders snapshot 1 >
', () => { + beforeEach(() => { + const consoleMock = jest.spyOn(console, 'warn'); + consoleMock.mockImplementation(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + generateSnapshots(stories as StoryFile); it('renders the text in the link', () => { @@ -63,4 +72,54 @@ describe('', () => { const link = screen.getByRole('link'); expect(link).toHaveFocus(); }); + + describe('emits warnings when misused', () => { + it('warns when inline links are using emphasis=low', () => { + const consoleMock = jest.spyOn(console, 'warn'); + consoleMock.mockImplementation(); + render( + + Click + , + ); + + expect(consoleMock).toHaveBeenCalledTimes(1); + }); + + it('warns when inline links have icons specified', () => { + const consoleMock = jest.spyOn(console, 'warn'); + consoleMock.mockImplementation(); + render( + + Click + , + ); + + expect(consoleMock).toHaveBeenCalledTimes(1); + }); + + it('warns when chevron-right is not used in low emphasis mode', () => { + const consoleMock = jest.spyOn(console, 'warn'); + consoleMock.mockImplementation(); + render( + + Click + , + ); + + expect(consoleMock).toHaveBeenCalledTimes(1); + }); + + it('warns when size is used with context standalone', () => { + const consoleMock = jest.spyOn(console, 'warn'); + consoleMock.mockImplementation(); + render( + + Click + , + ); + + expect(consoleMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index 55fbba83b..c54d3b8ac 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import React, { forwardRef } from 'react'; +import { assertEdsUsage } from '../../util/logging'; import type { Size } from '../../util/variant-types'; import Icon, { type IconName } from '../Icon'; @@ -23,7 +24,7 @@ export type LinkProps = * Where `Link` sits alongside other text and content: * * * **inline** - Inline link inherits the text size established within the `

` paragraph they are embedded in. - * * **standalone** - Users can choose from the available sizes. + * * **standalone** - Users can choose from the available sizes, and add trailing icons. * * **Default is `"inline"`**. * @@ -31,7 +32,7 @@ export type LinkProps = */ context?: 'inline' | 'standalone'; /** - * (trailing) icon to use with the link + * (trailing) icon to use with the link (when `context` is `"standalone"`) */ icon?: Extract; /** @@ -39,7 +40,9 @@ export type LinkProps = */ emphasis?: 'default' | 'high' | 'low'; /** - * Link size inherits from the surrounding text. + * The size of the link (when its context is "standalone"). + * + * **NOTE**: when `context` is `"inline"`, size is ignored (and inherits from the associated text container) */ size?: Extract; /** @@ -65,7 +68,7 @@ export const Link = forwardRef( context, emphasis = 'default', icon, - size = 'md', + size, variant = 'default', ...other }, @@ -83,6 +86,26 @@ export const Link = forwardRef( const iconSize = size && (['xl', 'lg'].includes(size) ? '1.5rem' : '1rem'); + assertEdsUsage( + [context === 'inline' && emphasis === 'low'], + 'Inline links cannot be lowEmphasis', + ); + + assertEdsUsage( + [context === 'inline' && !!icon], + 'Inline links cannot show icons', + ); + + assertEdsUsage( + [context === 'inline' && typeof size !== 'undefined'], + 'Size can only be used when context is "standalone"', + ); + + assertEdsUsage( + [icon === 'chevron-right' && emphasis !== 'low'], + 'Icon "chevron-right" only allowed when lowEmphasis is used', + ); + return ( {children} diff --git a/src/components/Link/__snapshots__/Link.test.tsx.snap b/src/components/Link/__snapshots__/Link.test.tsx.snap index 372e29a80..929132aa0 100644 --- a/src/components/Link/__snapshots__/Link.test.tsx.snap +++ b/src/components/Link/__snapshots__/Link.test.tsx.snap @@ -238,7 +238,7 @@ exports[` UsingExtendedLink story renders snapshot 1`] = ` exports[` passes class names down properly 1`] = ` @@ -248,7 +248,7 @@ exports[` passes class names down properly 1`] = ` exports[` passes test ids down properly 1`] = ` diff --git a/src/components/LoadingIndicator/LoadingIndicator.test.ts b/src/components/LoadingIndicator/LoadingIndicator.test.ts index b70818d03..dcdcecbf4 100644 --- a/src/components/LoadingIndicator/LoadingIndicator.test.ts +++ b/src/components/LoadingIndicator/LoadingIndicator.test.ts @@ -1,6 +1,7 @@ import { generateSnapshots } from '@chanzuckerberg/story-utils'; import * as stories from './LoadingIndicator.stories'; +import type { StoryFile } from '../../util/utility-types'; describe('', () => { - generateSnapshots(stories); + generateSnapshots(stories as StoryFile); }); diff --git a/src/components/Menu/Menu.test.tsx b/src/components/Menu/Menu.test.tsx index 9febdddf7..57c38f18e 100644 --- a/src/components/Menu/Menu.test.tsx +++ b/src/components/Menu/Menu.test.tsx @@ -6,6 +6,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { Menu } from './Menu'; import * as stories from './Menu.stories'; +import type { StoryFile } from '../../util/utility-types'; const { Default } = composeStories(stories); @@ -16,7 +17,7 @@ describe('

', () => { afterEach(() => { jest.resetAllMocks(); }); - generateSnapshots(staticStories, { + generateSnapshots(staticStories as StoryFile, { getElement: async () => { const user = userEvent.setup(); const triggerButton = await screen.findByRole('button'); diff --git a/src/components/Modal/Modal.module.css b/src/components/Modal/Modal.module.css index 247adefb8..3d9457b0c 100755 --- a/src/components/Modal/Modal.module.css +++ b/src/components/Modal/Modal.module.css @@ -19,19 +19,6 @@ width: 100%; } -/** - * The inverted background of the modal to provide contrast with the actual modal. - */ -.modal__overlay { - &.modal__overlay--emphasis-high { - background-color: rgb(from var(--eds-theme-color-background-utility-overlay-high-emphasis) r g b / 0.8); - } - - &.modal__overlay--emphasis-low { - background-color: rgb(from var(--eds-theme-color-background-utility-overlay-low-emphasis) r g b / 0.5); - } -} - /** * The modal container which positions the modal in the center of the screen. */ @@ -48,6 +35,65 @@ z-index: 1050; } +/** + * The content of the modal, which can wrap header, body, and footer. + */ +.modal__content { + position: relative; + overflow: hidden; + + /** + * This transparent border is for Window High Contrast Mode, which removes all + * background colors but makes borders 100% opacity black. Without this, the + * modal would have no clear boundary. + */ + border: var(--eds-theme-form-border-width) transparent + var(--eds-theme-color-background-utility-container); + + display: flex; + flex-direction: column; + + background-color: var(--eds-theme-color-background-utility-container); +} + +/** + * Header for the modal. + */ +.modal-header { + width: 100%; + + padding: calc(var(--eds-size-3) / 16 * 1rem) + calc(var(--eds-size-4) / 16 * 1rem); +} + +/** + * The body of the modal + */ +.modal-body { + flex: 1; + padding: 0 calc(var(--eds-size-4) / 16 * 1rem); +} + +/** + * Footer for the modal. + */ +.modal-footer { + width: 100%; + z-index: 1000; + + padding: calc(var(--eds-size-3) / 16 * 1rem) + calc(var(--eds-size-4) / 16 * 1rem); + + background-color: var(--eds-theme-color-background-utility-container); +} + +.modal-footer--sticky { + position: sticky; + bottom: 0; + + box-shadow: var(--eds-box-shadow-xl); +} + /** * Modal transition animations. */ @@ -81,61 +127,98 @@ opacity: 0; } -/** - * The content of the modal, which can wrap header, body, and footer. - */ -.modal__content { - position: relative; - overflow: hidden; - - /** - * This transparent border is for Window High Contrast Mode, which removes all - * background colors but makes borders 100% opacity black. Without this, the - * modal would have no clear boundary. - */ - border: var(--eds-theme-form-border-width) transparent var(--eds-theme-color-background-utility-container); - - display: flex; - flex-direction: column; - - background-color: var(--eds-theme-color-background-utility-container); -} - /** * The large modal size used for the lg/default modal. */ .modal__content--lg { - @media all and (min-width: $eds-bp-xs) { - width: 100%; - height: 100%; - } - - @media all and (min-width: $eds-bp-sm) { - max-height: 40rem; - max-width: 50rem; - } - - @media all and (min-width: $eds-bp-md) { - max-height: 40rem; - max-width: 50rem; + &.modal__content--height-fixed { + @media all and (min-width: $eds-bp-xs) { + width: 100%; + height: 100%; + max-height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-sm) { + max-height: 40rem; + max-width: 50rem; + } + + @media all and (min-width: $eds-bp-md) { + max-height: 40rem; + max-width: 50rem; + } + + @media all and (min-width: $eds-bp-lg) { + max-height: 40rem; + max-width: 60rem; + } + + @media all and (min-width: $eds-bp-xl) { + max-height: 40rem; + max-width: 60rem; + } } - @media all and (min-width: $eds-bp-lg) { - max-height: 40rem; - max-width: 60rem; + &.modal__content--height-auto { + @media all and (min-width: $eds-bp-xs) { + width: 100%; + height: auto; + max-height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-sm) { + max-width: 50rem; + max-height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-md) { + max-width: 50rem; + max-height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-lg) { + max-width: 60rem; + max-height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-xl) { + max-width: 60rem; + max-height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } } - @media all and (min-width: $eds-bp-xl) { - max-height: 40rem; - max-width: 60rem; - --modal-horizontal-padding: calc(var(--eds-size-8) / 16 * 1rem); + &.modal__content--height-max { + @media all and (min-width: $eds-bp-xs) { + width: 100%; + height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-sm) { + max-width: 50rem; + height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-md) { + max-width: 50rem; + height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-lg) { + max-width: 60rem; + height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } + + @media all and (min-width: $eds-bp-xl) { + max-width: 60rem; + height: calc(100vh - (var(--eds-size-12) / 16 * 1rem)); + } } } /** * The small modal size used for the modal. */ - .modal__content--sm { +.modal__content--sm { @media all and (min-width: $eds-bp-xs) { max-height: 30rem; max-width: 35rem; @@ -154,11 +237,9 @@ @media all and (min-width: $eds-bp-xl) { max-height: 30rem; max-width: 35rem; - --modal-horizontal-padding: calc(var(--eds-size-8) / 16 * 1rem); } } - /** * Allows scrolling of the modal content except for sticky footer. * This functionality is our intended scroll behavior but consuming teams can @@ -177,54 +258,25 @@ right: 0; } -/*------------------------------------*\ - # MODAL BODY -\*------------------------------------*/ - -/** - * The body of the modal - */ - .modal-body { - flex: 1; - padding: 0 calc(var(--eds-size-4) / 16 * 1rem); -} - -/*------------------------------------*\ - # MODAL FOOTER -\*------------------------------------*/ - -/** - * Footer for the modal. - */ - .modal-footer { - width: 100%; - z-index: 1000; - - padding: calc(var(--eds-size-3) / 16 * 1rem) calc(var(--eds-size-4) / 16 * 1rem); - - background-color: var(--eds-theme-color-background-utility-container); -} - -.modal-footer--sticky { - position: sticky; - bottom: 0; - - box-shadow: var(--eds-box-shadow-xl); +.modal-sub-title { + color: var(--eds-theme-color-text-utility-default-secondary); } -/*------------------------------------*\ - # MODAL HEADER -\*------------------------------------*/ - /** - * Header for the modal. + * The inverted background of the modal to provide contrast with the actual modal. */ - .modal-header { - width: 100%; +.modal__overlay { + &.modal__overlay--emphasis-high { + background-color: rgb( + from var(--eds-theme-color-background-utility-overlay-high-emphasis) r g b / + 0.8 + ); + } - padding: calc(var(--eds-size-3) / 16 * 1rem) calc(var(--eds-size-4) / 16 * 1rem); + &.modal__overlay--emphasis-low { + background-color: rgb( + from var(--eds-theme-color-background-utility-overlay-low-emphasis) r g b / + 0.5 + ); + } } - -.modal-sub-title { - color: var(--eds-theme-color-text-utility-default-secondary); -} \ No newline at end of file diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx index fd0782e3f..cd50c4607 100644 --- a/src/components/Modal/Modal.stories.tsx +++ b/src/components/Modal/Modal.stories.tsx @@ -2,7 +2,7 @@ import type { StoryObj, Meta } from '@storybook/react'; import React from 'react'; import { useState } from 'react'; -import { Modal, ModalContent } from './Modal'; +import { Modal } from './Modal'; import { Heading, Text } from '../../'; import { chromaticViewports, storybookViewports } from '../../util/viewports'; import Button from '../Button'; @@ -15,38 +15,9 @@ export default { // The modal is initially closed for most of these stories, // which renders testing it for visual regressions unhelpful. chromatic: { disableSnapshot: true }, - badges: ['intro-1.0', 'current-2.0'], + badges: ['intro-1.0', 'current-2.1'], }, tags: ['autodocs'], - argTypes: { - // For some reason, storybook is not able to pick up the doc.s automatically. Adding manually. - children: { - description: - 'Contains the sub-components for a Modal, including `.Header` , `.Title` , `.Body` , `.Footer` , `.Stepper`', - }, - open: { - type: 'boolean', - description: 'Whether or not the modal is visible.', - }, - hideCloseButton: { - description: - 'Hides the close button in the top right of the modal. **Default is `false`**.', - type: 'boolean', - }, - isScrollable: { - description: - 'Toggles scrollable variant of the modal. If modal is scrollable, footer is not, and vice versa.', - type: 'boolean', - }, - size: { - description: 'Max size of the modal, which responds to the viewport', - control: { - type: 'select', - }, - options: ['sm', 'lg'], - defaultValue: 'lg', - }, - }, decorators: [(Story) =>
{Story()}
], } as Meta; @@ -224,7 +195,7 @@ export const ContentDefault: Story = { render: (args) => (
- {}} @@ -274,6 +245,30 @@ export const Large: Story = { }, }; +/** + * Large modals can have height set to auto, which will allow the modal's height to vary based on the contents of the modal container. + * + * This can be as large as the viewport allows, or as short as the content specifies. + */ +export const LargeAuto: Story = { + ...Large, + args: { + ...Large.args, + height: 'auto', + }, +}; + +/** + * Large modals can have height set to max, which will take up the maxiumum vertical height allowed in the viewport. + */ +export const LargeMax: Story = { + ...Large, + args: { + ...Large.args, + height: 'max', + }, +}; + /** * `Modal` also allows for `small`. */ @@ -319,7 +314,7 @@ export const LayoutVertical: Story = { render: (args) => (
- {}} @@ -362,7 +357,7 @@ export const LayoutVerticalWithTertiary: Story = { render: (args) => (
- {}} @@ -401,7 +396,7 @@ export const WithCriticalButton: Story = { render: (args) => (
- {}} diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index da99f9cd0..e8b59bbbf 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -131,4 +131,26 @@ describe('Modal', () => { expect(renderMethod).toThrow(Error); consoleErrorMock.mockRestore(); }); + + it('prints a warning when height is used with size="sm"', () => { + const modalWithoutTitleOrAriaLabel = ( + {}} open size="sm"> + + Modal Title + + Modal body content. + Modal footer content. + + ); + const renderMethod = () => { + render(modalWithoutTitleOrAriaLabel); + }; + + // expect console error from react, suppressed. + const consoleWarningMock = jest.spyOn(console, 'warn'); + consoleWarningMock.mockImplementation(); + expect(renderMethod).not.toThrow(); + expect(consoleWarningMock).toHaveBeenCalledTimes(1); + consoleWarningMock.mockRestore(); + }); }); diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index c7a4ba4ff..3ff1a9970 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -3,6 +3,7 @@ import clsx from 'clsx'; import type { MutableRefObject, ReactNode } from 'react'; import React from 'react'; +import { assertEdsUsage } from '../../util/logging'; import type { ExtractProps } from '../../util/utility-types'; import type { Size } from '../../util/variant-types'; @@ -79,17 +80,24 @@ type ModalContentProps = { onClose: () => void; // Design API /** - * Max size of the modal, which responds to the viewport - * - * **Default is `"lg"`**. + * Determine how the height of the modal container is calculated when `size` is `"lg"`: + * - `"fixed"` applies the fixed dimensions, which will not adjust + * - `"auto"` applies a floating height dimension, that will fit to the content (can be smaller or larger than `"default"`) + * - `"max"` applies the maximum height within the viewport, leaving space along the top and bottom edges */ - size?: Extract; + height?: 'fixed' | 'auto' | 'max'; /** * Emphasis used on the backgound overlay (behind the modal) * * **Default is `"low"`**. */ overlayEmphasis?: 'low' | 'high'; + /** + * Fixed sizes for the modal's height and width. Used in conjunction with `height` when using size `lg`. + * + * **Default is `"lg"`**. + */ + size?: Extract; }; type ModalProps = ModalContentProps & { @@ -222,10 +230,11 @@ function childrenHaveModalTitle(children?: ReactNode): boolean { * * This is only exported for testing purposes; please do not import and use this directly. */ -export const ModalContent = (props: ModalContentProps) => { +const ModalContent = (props: ModalContentProps) => { const { children, className, + height = 'fixed', hideCloseButton = false, isScrollable, onClose, @@ -235,6 +244,7 @@ export const ModalContent = (props: ModalContentProps) => { const componentClassName = clsx( styles['modal__content'], + height && styles[`modal__content--height-${height}`], isScrollable && styles['modal__content--scrollable'], size && styles[`modal__content--${size}`], className, @@ -253,9 +263,7 @@ export const ModalContent = (props: ModalContentProps) => { onClick={onClose} rank="tertiary" variant="neutral" - > - Close - + > )} {children}
@@ -295,6 +303,12 @@ export const Modal = (props: ModalProps) => { } } + // check to make sure folks aren't using size="lg" with "height" + assertEdsUsage( + [rest.size !== 'lg' && typeof rest.height !== 'undefined'], + 'Height is only supported when size is set to "lg"', + ); + const componentClassName = clsx(styles['modal'], modalContainerClassName); return ( @@ -440,6 +454,7 @@ FocusableModalBody.displayName = 'Modal.Body'; StickyModalFooter.displayName = 'Modal.Footer'; Modal.Header = VariantModalHeader; +Modal.Content = ModalContent; Modal.Title = ModalTitle; Modal.SubTitle = ModalSubTitle; Modal.Body = FocusableModalBody; diff --git a/src/components/Modal/__snapshots__/Modal.test.tsx.snap b/src/components/Modal/__snapshots__/Modal.test.tsx.snap index 1bf94df45..fba0b5faa 100644 --- a/src/components/Modal/__snapshots__/Modal.test.tsx.snap +++ b/src/components/Modal/__snapshots__/Modal.test.tsx.snap @@ -2,7 +2,7 @@ exports[`Modal ContentDefault story renders snapshot 1`] = `