diff --git a/.storybook/components/Docs/GettingStarted.stories.mdx b/.storybook/components/Docs/GettingStarted.stories.mdx index 18cd5cf34..992495e34 100644 --- a/.storybook/components/Docs/GettingStarted.stories.mdx +++ b/.storybook/components/Docs/GettingStarted.stories.mdx @@ -30,3 +30,20 @@ Storybook offers several facilities to demonstrate how components behave. Some c - **Storybook View Options** allow for controlling the viewport, background color, and other details to help understand the component layout. Visually test how the component responds to different layouts Storybook provides a lot of functionality, including [keyboard navigation](https://storybook.js.org/docs/react/get-started/browse-stories#sidebar-and-canvas). Explore [the docs](https://storybook.js.org/docs/react/get-started/introduction) to learn more. + +### IDE Integrations + +Since EDS provides many color tokens, it may prove useful to add some integrations to the IDE to show visual references for the colors in use. + +- Install the [CSS Var Complete - VS Code Plugin](https://marketplace.visualstudio.com/items?itemName=phoenisx.cssvar) which provides better Intellisense while writing CSS and referencing CSS variables. +- Add the following settings in your workspace settings file: + +```jsonc +{ + // ...rest of the settings here + "cssvar.files": [ + "node_modules/@chanzuckerberg/eds/lib/index.css" + ] +} +``` +- Restart VSCode \ No newline at end of file diff --git a/.storybook/components/Docs/Guidelines/CodeGuidelines.stories.mdx b/.storybook/components/Docs/Guidelines/CodeGuidelines.stories.mdx index e095a3741..3ae8e6dfd 100644 --- a/.storybook/components/Docs/Guidelines/CodeGuidelines.stories.mdx +++ b/.storybook/components/Docs/Guidelines/CodeGuidelines.stories.mdx @@ -301,15 +301,15 @@ You can continue to use the `Icon` components' `color` prop with JavaScript vari ## Tailwind utility classes -EDS uses [tailwind utility classes](https://tailwindcss.com/docs/padding) (e.g. `mb-0` and `p-0`) inline in `*.stories.tsx` files to quickly add small styling tweaks, like spacing (e.g. ``). This reduces the need for CSS module files made specifically for stories. Use the `!` modifier to override default component styles (e.g. ``). +EDS uses [tailwind utility classes](https://tailwindcss.com/docs/), (e.g., `mb-0` and `p-0`) inline in `*.stories.tsx` files to demonstrate allowed compositions and example implementations. Consider installing the VSCode extension [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) for autocomplete, linting, and hover previews. ## Theming conventions -EDS is a [themeable design system](https://bradfrost.com/blog/post/creating-themeable-design-systems/) that incorporates some high-level UI application variables to make easy systematic changes to the UI. +EDS is a [Headless design system](https://bradfrost.com/blog/post/creating-themeable-design-systems/) that incorporates some high-level UI application variables to make easy systematic changes to the UI. -This is a "lightly" themed system, meaning that only a few variables (such as key UI colors and other properties like border radius) are available for theming. +Learn more about EDS Theming [here](./?path=?path=/docs/documentation-theming--docs). ### Design Tokens diff --git a/.storybook/components/Docs/Guidelines/Theming.stories.mdx b/.storybook/components/Docs/Guidelines/Theming.stories.mdx index 5c97b43d3..77b02fead 100644 --- a/.storybook/components/Docs/Guidelines/Theming.stories.mdx +++ b/.storybook/components/Docs/Guidelines/Theming.stories.mdx @@ -2,131 +2,120 @@ import { Canvas, Meta } from '@storybook/blocks'; -# Theming overview +# Theming -"Theming", in the context of EDS, is the process of overriding the default styles of EDS components to match a different brand (or "theme"). We include useful examples under "Pages": +Below are instructions on how to use the tooling, configs, and tokens to define custom theme values for a project. -- A [wireframe theme](./?path=/story/pages-theming-wireframedemo--default) (an unbranded theme that can be used for prototyping a product before it has an official visual style). +## Using Tailwind with the Default Theme -Below are instructions on how to use the tooling and tokens to define custom theme values for a project. +Out of the box, EDS provides a basic tailwind configuration to use in any project. The provided EDS tailwind config hooks up EDS tokens to useful utility classes and some screen sizes. To import the tailwind config into the app's tailwind config, supply the [theme](https://tailwindcss.com/docs/theme) property for use: -## How to apply a theme in another product +```ts +// in your tailwind.config.ts +import edsConfig from '@chanzuckerberg/eds/tailwind.config'; -EDS comes with some tooling to allow easy transfer of theme data from Figma (or some style-dictionary compatible format) into code. +module.exports = { + content: ['./app/**/*.{ts,tsx,jsx,js}'], + theme: edsConfig.theme, + // ... any other tailwind config +}; +``` -* `eds-init-theme` - This command sets up the initial file(s) for theming your application -* `eds-apply-theme` - This command parses the style dictionary files to generate the tokens used by EDS (and tailwind, or other tools) +If you only want part of the provided settings, you can review the [Tailwind Theme Customization][tailwind-theme] documentation, and the contents of the [provided config][eds-tailwind-config], and apply the parts you want to use. -Each of these tools reads config to figure out where to read/write files. This can be defined in several ways, e.g., a top-level file `.edsrc.json`, or as a key-value set in package.json. Example: +[tailwind-theme]: https://tailwindcss.com/docs/theme +[eds-tailwind-config]: https://github.com/chanzuckerberg/edu-design-system/blob/main/tailwind.config.ts -`package.json` +
-```json -"eds": { - "src": "src/components/", - "dest": "src/components/" -}, -``` +## Setting up and using the theming tooling + +EDS comes with some optional tooling to allow easy transfer of theme data from Figma (or some style-dictionary compatible format) into code. -`.edsrc.json` +- `eds-init-theme` - This command creates the initial file(s) for theming your application +- `eds-apply-theme` - This command parses the local config file to generate the tokens used by EDS components and tools + +Each of these tools reads config to figure out where to read/write files. + +First, create a configuration file to determine the source and destination directories. This can be defined in a new file `.edsrc.json` in your project root. Example: ```json +// in a new file .edsrc.json { "src": "src/components/", "dest": "src/components/" } ``` -`src` determines where the core theme file will be copied to (upon init) OR read from (upon apply), and `dest` determines where the processed files will be written to. +`src` determines where the core theme file will be copied to (upon running `eds-init-theme`) OR read from (upon running `eds-apply-theme`), and `dest` determines where the generated project files will be written to. Once created, you can use the provided commands. ### eds-init-theme -This will create an initial JSON file `app-theme.json` that defines ALL the available tokens for EDS that you can edit. +Command to run: `npx eds-init-theme` -EDS comes pre-packaged with many tokens that define the base style and character of the system. Users of EDS can theme certain aspects of all components, or details on specific components. +This will create a new JSON file `app-theme.json` that defines ALL the available tokens for EDS that you can edit. It will copy the template file to configured `src` path in your project. -```json -{ - "eds": { - "anim": { - "fade": { - "quick": { - "value": "0.15s" - }, - "long": { - "value": "0.4s" - } - }, - "move": { - "quick": { - "value": "0.15s" - }, - "medium": { - "value": "0.3s" - }, - "long": { - "value": "0.4s" - } - }, - "ease": { - "value": "ease" - } - }, - // ...other token values - }, -} -``` +This file is a baseline config to be used later in the process. ### eds-apply-theme -After making changes to the `app-theme.json` to reflect what has been defined by design, update the project's theme files by running `npx eds-apply-theme`. - -Once run, you will have a CSS file `app-theme.css` that includes a set of token values as CSS variables, which can be used in the app as appropriate. - -```css -/** - * Do not edit directly - * Generated on Sunday, 01 Jan 2023 12:34:56 GMT - * To update, edit app-theme.json, then run `npx eds-apply-theme` - */ - -:root { - --eds-anim-fade-quick: 0.15s; - --eds-anim-fade-long: 0.4s; - --eds-anim-move-quick: 0.15s; - --eds-anim-move-medium: 0.3s; - --eds-anim-move-long: 0.4s; - --eds-anim-ease: ease; -/* ...other token values... */ -} -``` +Command to run: `npx eds-apply-theme` -Add this file to your core app root file. +Using `eds-apply-theme` will read in the newly-created `app-theme.json` file, and create the tokens to use in your project. -This also generates an additional file `app-tailwind-theme.config.json` which contains [useful tailwind configuration](https://github.com/chanzuckerberg/edu-design-system?tab=readme-ov-file#tailwind-setup) for EDS-specific utility classes -This will also show a preview of the tokens in your IDE of choice. To use this config, replace the import from the package with a link to this files location: +Once run, you will have a set of theme files written to the configured `dest` path: -```diff --const edsConfig = require('@chanzuckerberg/eds/tailwind.config'); -+const edsConfig = require('./src/components/app-tailwind-theme.config'); -``` +- `app-theme.css` (CSS file containing custom CSS variables with the theme values for the application) +- `app-tailwind-theme.config.json` (Configuration file for advanced tailwind configuration in the application) -That's it! Now, the theme will be applied to the tokens used by EDS components. To make other changes, edit `app-theme.json`, then re-run `npx eds-apply-theme`. +To use, add this file to your core app root file **after** where the imported EDS's `@chanzuckerberg/eds/index.css` file is inserted. -**NOTE**: do not edit this file directly. Instead, follow the instructions at the top of the file! +## Custom Theming and Tailwind -## How to manually apply a theme in another product +When you have your own custom theme, you can use the tokens provided in `app-tailwind-theme.config.json` to do advanced tailwind configuration. This file contains all the tokens in JSON format, mapped to the literal values in your local theme. -You can also manage the creation of theme token definitions manually. In EDS, theming is implemented by overriding the values of the CSS variables representing tokens, which the EDS components use in their styles. This should update the style of the components to match the branding of a different product with minimum manual CSS styling overrides. (Some manual styling overrides will be necessary though because we don't have tokens for every little detail. In those cases, we could create a new token to make those overrides easier if it looks like something that could very well be useful for other products as well.) +You can use similar import values to what is in `@chanzuckerberg/eds/tailwind.config.ts` in your local tailwind configuration file: -These CSS variables overrides lives in the products using EDS components. This allows product teams to quickly iterate on their theme without making changes to EDS itself. +```ts +// in your tailwind.config.ts file +import type { Config } from 'tailwindcss'; +import baseConfig from '@chanzuckerberg/eds/tailwind.config'; +import {eds as customTokens} from "/app-tailwind-theme.config"; // where is the path configured in .edsrc.json -You can find the full list of CSS variables in [src/tokens-dist/css/variables.css](https://github.com/chanzuckerberg/edu-design-system/blob/main/src/tokens-dist/css/variables.css), and you can see examples of overriding them in [.storybook/pages/WireframeDemo/GlobalStyles.module.css](https://github.com/chanzuckerberg/edu-design-system/blob/main/.storybook/pages/WireframeDemo/GlobalStyles.module.css). +const { + background: backgroundColorTokens, + border: borderColorTokens, + text: textColorTokens, + ...colorTokens +} = customTokens.theme.color; -If you're looking to set up a prototype using the [wireframe theme](./?path=/story/pages-theming-wireframedemo--default), you can copy and paste the variables defined in [.storybook/pages/WireframeDemo/GlobalStyles.module.css](https://github.com/chanzuckerberg/edu-design-system/blob/main/.storybook/pages/WireframeDemo/GlobalStyles.module.css). (The placeholder images will need to be added separately.) +// export the following +export default { + ... // your local content export + theme: { + colors: { + ...colorTokens, + }, + extend: { + backgroundColor: { + ...backgroundColorTokens, + }, + borderColor: { + ...borderColorTokens, + }, + textColor: { + ...textColorTokens, + }, + }, + screens: { + ...baseConfig.theme.screens, // you can mix in any base config values into the local theme + } + ... // any local theme settings + } +} satisfies Config -If you are trying to customize the styling of a component but find that the style you want to override does not yet have a token for you to redefine, you can reach out to us to discuss whether a new token should be added. +``` -## How to support theming in EDS +
-Since other products rely on CSS variable tokens to theme EDS components, it's very important that, when working with color, components in the EDS package only use CSS variables representing tier 2 and tier 3 tokens for styling. EDS component styling should never use tier 1 tokens, JavaScript variables representing tokens, or raw hex codes. Non-color styling can use tokens from any tier. (This is only relevant to the EDS components themselves; examples in storybook or in other products do not need to follow this rule.) +If there are any future updates to the theme, edit the contents of `app-theme.json`, then re-run `npx eds-apply-theme`. Then commit the changes, and that's it! \ No newline at end of file diff --git a/.storybook/components/Docs/Guidelines/Tokens.stories.mdx b/.storybook/components/Docs/Guidelines/Tokens.stories.mdx index 8d8873830..fdbd78ee5 100644 --- a/.storybook/components/Docs/Guidelines/Tokens.stories.mdx +++ b/.storybook/components/Docs/Guidelines/Tokens.stories.mdx @@ -23,7 +23,6 @@ EDS is a [themeable design system](https://bradfrost.com/blog/post/creating-them - [Color](#tier-2-colors) - [Form](#tier-2-forms) - [Tier 3 Component Tokens](#tier-3-component-tokens) -- [Tailwind Class Tokens](#tailwind-class-tokens) ## Style Dictionary @@ -33,7 +32,9 @@ Tokens are defined as a collection of JSON files, which then get converted by St ## Design token architecture -Design tokens live at `src/design-tokens`, and follows this directory structure: +Design tokens live at `src/design-tokens`. Primitive (tier-1) tokens live in `primitives.json`, and theme (tier-2/tier-3) tokens live in `themes.json`. + +For typography tokens, thes directory structure is stored in the following tree: ```css design-tokens @@ -150,28 +151,3 @@ With the rest matching a corresponding use for the given component. For example, The suffix here is arbitrary, but uses similar naming to tier two tokens. When possible, any new tier 3 tokens should be a more specifically named tier 2 token, to keep the naming consistent. See the current set of tier 3 tokens [in storybook](/story/design-tokens-tier-3-component--colors). - - -## Tailwind Class Tokens - -If the EDS tailwind config theme is being used, Tier 2 and tier 3 color tokens are available as a part of tailwind utility classes, and can be used to apply to specific attributes to a component. Background(prefix: bg-), border(prefix: border-), and text(prefix: text-) colors will only be available for themselves specifically. e.g.: - -### These will not work - -```html - -
- -
-``` - -### These will work -```html - -
- -
- -
-``` \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d6b3dd4..531cef849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [13.10.0](https://github.com/chanzuckerberg/edu-design-system/compare/v13.9.0...v13.10.0) (2024-02-01) + + +### Features + +* **Select:** add ability to handle click and change event handlers ([#1839](https://github.com/chanzuckerberg/edu-design-system/issues/1839)) ([54a3de8](https://github.com/chanzuckerberg/edu-design-system/commit/54a3de8e5d1e9416b1ff24a18499ee5f02db3888)) +* **Select:** add support for label prop ([#1837](https://github.com/chanzuckerberg/edu-design-system/issues/1837)) ([c032ff2](https://github.com/chanzuckerberg/edu-design-system/commit/c032ff2b35f074076237aab0786325cc7237fa3a)) + ## [13.9.0](https://github.com/chanzuckerberg/edu-design-system/compare/v13.8.1...v13.9.0) (2024-01-19) diff --git a/README.md b/README.md index e2ab16abf..86f696d20 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,26 @@ Education Design System (EDS) is a repository of [presentational](https://medium ## Installation -First install the package. +First, install the package. ```bash -# via npm +# using npm npm install --save @chanzuckerberg/eds -# or, if using Yarn +# or using Yarn yarn add @chanzuckerberg/eds ``` -## App Setup +## Setting up EDS in your project -Import the EDS stylesheet and tokens somewhere in your app root, e.g. an `init.ts` or `app.ts` file: +Import the EDS stylesheets somewhere in your app root, e.g. an `init.ts` or `app.ts` file: ```js -import '@chanzuckerberg/eds/index.css'; -// optionally import EDS font faces -// import '@chanzuckerberg/eds/fonts.css'; +import '@chanzuckerberg/eds/index.css'; // Includes relevant styles and tokens for EDS +import '@chanzuckerberg/eds/fonts.css'; // Includes font files if using the built in theme fonts ``` -We also surface an `--eds-font-size-base` property to set your base `rem` font size, eg: +Also, include this in your base / reset styles to allow configuation of the pixel-to-rem ratio: ```css html { @@ -34,85 +33,21 @@ html { } ``` -### Tailwind Setup - -The EDS Tailwind theme provides many EDS [tokens][tokens] and some screen sizes. Import the tailwind config into the app's tailwind config and supply the [content](https://tailwindcss.com/docs/content-configuration) property for use: - - -#### Applying all of the EDS tokens to Tailwind - -To take all of what EDS provides (base colors, and extended utility classes for named tokens), use the following: - -```js -const edsConfig = require('@chanzuckerberg/eds/tailwind.config'); - -module.exports = { - content: ['./app/**/*.{ts,tsx,jsx,js}'], - theme: edsConfig.theme, -}; -``` - -This will replace the default color tokens that come [with Tailwind](https://tailwindcss.com/docs/customizing-colors) with those defined by EDS. **NOTE**: this might cause regressions in your project, if you have been using the default colors provided by tailwind. - -#### Applying the EDS tailwind extensions piecemeal - -If you want a gentler transition to using EDS tailwind config, you can instead import **just** the extended values: - - -```js -const edsConfig = require('@chanzuckerberg/eds/tailwind.config'); - -module.exports = { - // ... - theme: { - extend: { - ...edsConfig.theme.extend - } - } -}; -``` - -This will add in the utility classes for properties like background color `bg-*`, border `border-*`, and text color `text-*`. These match the styles and variables defined in Figma designs. - -Refer to the [tokens tailwind section][tokens] for usage guidelines if your project uses the theming tooling. - -[tokens]: https://chanzuckerberg.github.io/edu-design-system/?path=/docs/documentation-guidelines-tokens--docs - -### CSS Variable Setup - -EDS also provides the tokens used in the internal styles, to use in any custom component recipes and designs. If using VSCode, you can set up the IDE to expose the token values and perform autocomplete: - -1. Install the [CSS Var Complete](https://marketplace.visualstudio.com/items?itemName=phoenisx.cssvar) VSCode extension -2. Add the following setting to your user or workspace settings file - -```jsonc -{ - // ...rest of the settings here - "cssvar.files": [ - "node_modules/@chanzuckerberg/eds/lib/index.css" - ] -} -``` -3. Restart VSCode - - ### Theming Setup -Refer to the "EDS Token and Theme Tools" in [the tokens documentation](https://chanzuckerberg.github.io/edu-design-system/?path=/docs/documentation-theming--docs) to learn about the optional tooling setup. +For more information and configuration options, read the [Theming Overview][theming-docs]. -## Usage +## EDS Component Usage Import any of the components from the top-level package: -```js +```jsx // Import components by name at the top of your file import { Heading } from '@chanzuckerberg/eds'; -``` -and then use them in your React components: +// ...and then use them in your React components: -```jsx - + Coffee! ``` @@ -121,11 +56,16 @@ EDS provides a [suite](https://chanzuckerberg.github.io/edu-design-system/) of c ## Development -This project is under **active development**. See [CONTRIBUTING.md](./docs/CONTRIBUTING.md) for more information on how to contribute to EDS. Also, read our [guidelines](https://chanzuckerberg.github.io/edu-design-system/?path=/story/documentation-guidelines-code-guidelines--page) for additional information. +This project is under **active development**. See [CONTRIBUTING.md][contributing] for more information on how to contribute to the Design System and IDE (VSCode) setup. Also, read our [guidelines][guidelines] for additional information on how we build EDS components. -Instead, if you want to report an issue, you can [open an issue](https://github.com/chanzuckerberg/edu-design-system/issues). +Instead, if you want to report an issue, you can [open an issue][gh-issue]. -This project is governed under the [Contributor Covenant](https://www.contributor-covenant.org/) code of conduct. +This project is governed under the [Contributor Covenant][contribution-covenant] code of conduct. + +[contributing]: ./docs/CONTRIBUTING.md +[guidelines]: https://chanzuckerberg.github.io/edu-design-system/?path=/docs/documentation-guidelines-code-guidelines--docs +[gh-issue]: https://github.com/chanzuckerberg/edu-design-system/issues +[contribution-covenant]: https://www.contributor-covenant.org/ ## Reporting Security Issues @@ -133,4 +73,6 @@ See our [Security Readme](https://github.com/chanzuckerberg/edu-design-system/bl ## FAQ, More Information, and Support -Please review our Education Design System Site (SSO Required): [/Paper](https://eds.czi.design/0843bc428/p/581284-education-design-system) +Please review our Education Design System Site (SSO Required) [here](https://eds.czi.design/0843bc428/p/581284-education-design-system). + +[theming-docs]: https://chanzuckerberg.github.io/edu-design-system/?path=/docs/documentation-theming--docs \ No newline at end of file diff --git a/package.json b/package.json index 0162b6782..0f31d7ad7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chanzuckerberg/eds", - "version": "13.9.0", + "version": "13.10.0", "description": "The React-powered design system library for Chan Zuckerberg Initiative education web applications", "author": "CZI ", "homepage": "https://github.com/chanzuckerberg/edu-design-system", @@ -102,7 +102,7 @@ "react-popper": "^2.3.0", "react-portal": "^4.2.2", "react-uid": "^2.3.3", - "style-dictionary": "^3.9.1", + "style-dictionary": "^3.9.2", "svg4everybody": "^2.1.9" }, "devDependencies": { @@ -117,26 +117,26 @@ "@chanzuckerberg/story-utils": "^4.0.0", "@commitlint/cli": "^18.4.4", "@commitlint/config-conventional": "^18.4.4", - "@geometricpanda/storybook-addon-badges": "^2.0.0", + "@geometricpanda/storybook-addon-badges": "^2.0.1", "@omlet/cli": "^1.2.2", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", "@size-limit/file": "^8.2.6", - "@storybook/addon-a11y": "^7.6.7", - "@storybook/addon-essentials": "^7.6.7", - "@storybook/addon-interactions": "^7.6.7", - "@storybook/addon-links": "^7.6.7", + "@storybook/addon-a11y": "^7.6.10", + "@storybook/addon-essentials": "^7.6.10", + "@storybook/addon-interactions": "^7.6.10", + "@storybook/addon-links": "^7.6.10", "@storybook/addon-styling": "^1.3.7", - "@storybook/react": "^7.6.7", - "@storybook/react-webpack5": "^7.6.7", + "@storybook/react": "^7.6.10", + "@storybook/react-webpack5": "^7.6.10", "@storybook/testing-library": "^0.2.2", "@storybook/testing-react": "^2.0.1", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.11", - "@types/node": "^20.11.0", - "@types/react": "^18.2.47", + "@types/node": "^20.11.5", + "@types/react": "^18.2.48", "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.2.18", "@types/react-portal": "^4.0.7", @@ -146,7 +146,7 @@ "copyfiles": "^2.4.1", "eslint": "^8.56.0", "eslint-config-prettier": "^9.0.0", - "eslint-plugin-jest": "^27.6.2", + "eslint-plugin-jest": "^27.6.3", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-testing-library": "^6.2.0", @@ -171,8 +171,8 @@ "rollup-plugin-postcss": "^4.0.2", "size-limit": "^8.2.6", "standard-version": "^9.5.0", - "storybook": "^7.6.7", - "style-dictionary": "^3.9.1", + "storybook": "^7.6.10", + "style-dictionary": "^3.9.2", "stylelint": "^15.11.0", "stylelint-config-recommended": "^13.0.0", "tailwindcss": "^3.4.1", diff --git a/src/components/Select/Select.module.css b/src/components/Select/Select.module.css index 4aace8b51..d023e59ed 100644 --- a/src/components/Select/Select.module.css +++ b/src/components/Select/Select.module.css @@ -13,10 +13,12 @@ /** * Compact variant. + * TODO: remove treatment for compact, as it only adjusts the component width, not its overall padding */ .select--compact { width: min-content; } + .select-button--compact { padding: var(--eds-size-half); } @@ -29,6 +31,10 @@ display: inline-block; } +.select__label--disabled { + color: var(--eds-theme-color-text-disabled); +} + /** * Wraps the Label and the optional/required indicator. */ diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index b81c4ef0d..dd019a012 100644 --- a/src/components/Select/Select.stories.tsx +++ b/src/components/Select/Select.stories.tsx @@ -1,8 +1,6 @@ import type { StoryObj, Meta } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; -import clsx from 'clsx'; import React from 'react'; -import type { OptionsAlignType, VariantType } from './Select'; import { Select } from './Select'; import Icon from '../Icon'; @@ -19,6 +17,11 @@ const meta: Meta = { disable: true, }, }, + children: { + control: { + type: null, + }, + }, value: { table: { description: 'The value of the select field (when controlled)', @@ -39,21 +42,33 @@ const meta: Meta = { }, }, }, + onClick: { + description: + 'Optional click handler. Fires after `onChange`, when a value in the dropdown popover is picked', + table: { + type: { + summary: 'SyntheticEvent', + detail: + 'See: https://react.dev/reference/react-dom/components/common#react-event-object', + }, + default: 'void', + }, + }, + onChange: { + description: 'Optional change handler. Fires when a value is selected', + table: { + type: { + summary: 'SelectOption', + detail: + 'An object with at least label (as string) and any other useful key/value pairs', + }, + }, + }, }, }; export default meta; -type Props = { - 'aria-label'?: string; - labelComponent?: React.ReactNode; - optionsAlign?: OptionsAlignType; - optionsClassName?: string; - variant?: VariantType; - disabled?: boolean; - value?: SelectOption; -}; - type SelectOption = { key: string; label: string; @@ -74,91 +89,6 @@ const exampleOptions: SelectOption[] = [ }, ]; -function InteractiveExampleUsingChildren(props: Props) { - const { variant, optionsAlign, optionsClassName, disabled, value } = props; - const compact = variant === 'compact'; - - const [selectedOption, setSelectedOption] = React.useState< - SelectOption | undefined - >(value); - - return ( -
- -
- ); -} - -// This story just tests the case where a function in passed in that wraps the children. -function InteractiveExampleUsingFunctionChildren() { - const [selectedOption, setSelectedOption] = - React.useState<(typeof exampleOptions)[0]>(); - - return ( -
- -
- ); -} - /** * Play function to open a menu item */ @@ -191,35 +121,115 @@ const selectCat: StoryObj['play'] = async (playOptions) => { await userEvent.click(selectButton); }; +/** + * The simplest and default case, using the options, button, and button wrapper. + * This shows how to reflect the value in the button upon selection, and how to generate + * a set of options from a list. + * + * **NOTE**: for select value data types, `{label: string}` is required, but any other key/value pairs are allowed. + */ export const Default: StoryObj = { + args: { + label: 'Favorite Animal', + 'data-testid': 'dropdown', + defaultValue: exampleOptions[0], + name: 'select', + children: ( + <> + + {({ value, open }) => ( + + {value.label} + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, parameters: { docs: { source: { code: ` -// props: {aria-label: 'Favorite Animal'} -function InteractiveExampleUsingChildren(props: Props) { - const { variant, optionsAlign, optionsClassName, disabled, value } = props; - const compact = variant === 'compact'; +`, + }, + }, + }, +}; - const [selectedOption, setSelectedOption] = React.useState< - SelectOption | undefined - >(value); +/** + * Instead of a render prop for `Select.Button`, you can forego the render prop for the button and use static text instead. + * This mode is also useful if you want to use a controlled component and manage state yourself. + */ +export const WithStandardButton: StoryObj = { + args: { + label: 'Favorite Animal', + 'data-testid': 'dropdown', + defaultValue: exampleOptions[0], + name: 'standard-button', + children: ( + <> + - Select Option - + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, +}; - return ( -
- -
- ); -}`, + + ), + }, + parameters: { + docs: { + source: { + code: ` +`, }, }, }, - render: () => ( - - ), +}; + +/** + * `Select` allows for event handlers to be added to the component. + * + * * `onChange` fires when a value is selected (with value of type `SelectOption`) + * + * If not using a render prop, you can also add an `onClick` handler to `Select.Button` directly + * + * * `onClick` fires when the trigger (`.buttonWrapper`) is clicked + * + * **NOTE**: `onClick` has no function when using a render prop + */ +export const EventHandlingOnStandardButton: StoryObj = { + args: { + ...Default.args, + children: ( + <> + console.log('external click', args)}> + - Select Option - + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + onChange: (args: SelectOption) => console.log('external change', args), + }, +}; + +/** + * You can select a different option to show when rendered. + */ +export const WithSelectedOption: StoryObj = { + args: { + ...Default.args, + 'aria-label': 'Favorite Animal', + defaultValue: exampleOptions[1], + }, }; /** @@ -248,14 +320,10 @@ function InteractiveExampleUsingChildren(props: Props) { * * `interactive-select[label]` * * `interactive-select[key]` * - * **NOTE**: for select value data types, `{label: string}` is required, but any other key/value pairs are allowed. */ export const WithFieldName: StoryObj = { args: { - 'aria-label': 'some label', - 'data-testid': 'dropdown', - defaultValue: exampleOptions[0], - name: 'select', + ...Default.args, children: ( <> @@ -292,7 +360,7 @@ export const UncontrolledHeadless: StoryObj = { <> {({ value, open, disabled }) => ( - + )} @@ -305,36 +373,6 @@ export const UncontrolledHeadless: StoryObj = { ), }, - parameters: { - docs: { - source: { - code: ` - - - `, - }, - }, - }, }; /** @@ -365,188 +403,99 @@ export const StyledUncontrolled: StoryObj = { ), }, - parameters: { - docs: { - source: { - code: ` - - - `, - }, - }, - }, }; -export const Disabled: StoryObj = { - render: () => ( - - ), +/** + * The field trigger width can be set with utility classes. By default, dropdown popover will exppand to match the width. + */ +export const AdjustedWidth: StoryObj = { + args: { + ...Default.args, + className: 'w-60', + }, }; -export const DefaultWithVisibleLabel: StoryObj = { +/** + * If you want a different width for the trigger and the dropdown popover, you can control them separately. + */ +export const SeparateButtonAndMenuWidth: StoryObj = { + args: { + ...Default.args, + className: 'w-40', + optionsClassName: 'w-96', + }, + play: selectCat, parameters: { - docs: { - source: { - code: ` -// refer to "Default" story code with: -// props: {aria-label: 'Favorite Animal', labelComponent: Favorite Animal}`, - }, + chromatic: { + diffIncludeAntiAliasing: false, + diffThreshold: 0.72, }, }, - render: () => ( - Favorite Animal} - /> - ), + decorators: [(Story) =>
{Story()}
], }; -export const Compact: StoryObj = { - parameters: { - docs: { - source: { - code: '// refer to "Default" story code', - }, - }, +/** + * Each Select can be marked as disabled. This will update the visual treatment to indicate the field cannot be changed (but by default + * will show the selected value). + */ +export const Disabled: StoryObj = { + args: { + ...Default.args, + disabled: true, }, - render: () => ( - - ), }; +/** + * Having a visible label is not necessary. In those cases, use `aria-label` to set a accessible label for the field + */ export const NoVisibleLabel: StoryObj = { - parameters: { - docs: { - source: { - code: '// refer to "Default" story code', - }, - }, + args: { + ...Default.args, + label: undefined, + 'aria-label': 'hidden label', }, - render: () => ( - - ), }; +/** + * Options for each `Select` can be aligned on different sides of the target button. Options for `placement` defined by + * PopperJS. + * + * More information: https://popper.js.org/docs/v2/constructors/#options + */ export const OptionsRightAligned: StoryObj = { parameters: { - docs: { - source: { - code: '// refer to "Default" story code', - }, - }, chromatic: { delay: 300, }, }, - render: () => ( - - ), - play: openMenu, -}; - -export const OptionsLeftAligned: StoryObj = { - parameters: { - docs: { - source: { - code: '// refer to "Default" story code', - }, - }, - chromatic: { - delay: 300, - }, + args: { + ...Default.args, + className: 'w-60', + optionsClassName: 'w-96', + placement: 'bottom-end', }, - render: () => ( - - ), play: openMenu, + decorators: [(Story) =>
{Story()}
], }; -export const SeparateButtonAndMenuWidth: StoryObj = { - render: () => ( - - ), - play: selectCat, - parameters: { - chromatic: { - diffIncludeAntiAliasing: false, - diffThreshold: 0.72, - docs: { - source: { - code: '// refer to "Default" story code', - }, - }, - }, - }, -}; - -export const UsingChildrenProp: StoryObj = { - parameters: { - docs: { - source: { - code: '// refer to "DefaultWithVisibleLabel" story code', - }, - }, - }, - render: () => ( - Favorite Animal} - /> - ), -}; - -export const UsingFunctionChildrenProp: StoryObj = { - parameters: { - docs: { - source: { - code: ` -function InteractiveExampleUsingFunctionChildren() { - const [selectedOption, setSelectedOption] = - React.useState<(typeof exampleOptions)[0]>(); +/** + * As an alternative rendering method, you can use several types of render props for fine-grained control of the button rendering, and + * the rendering of the list itself. Here, we use a render prop to control the contents of `Select` + * + * For more information on `Select` render props, review: https://headlessui.com/react/listbox#using-render-props + */ +export const UsingFunctionProps: StoryObj = { + render: () => { + const [selectedOption, setSelectedOption] = + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useState<(typeof exampleOptions)[0]>(); - return ( -
+ return ( -
- ); -}`, - }, - }, + ); }, - render: () => , }; +/** + * This shows the contents of `Select` upon render. Mostly to demonstrate it is possible, to capture a snapshot of the appearance. + */ export const OpenByDefault: StoryObj = { ...Default, parameters: { @@ -600,22 +544,3 @@ export const OpenByDefault: StoryObj = { }, play: selectCat, }; - -export const WithSelectedOption: StoryObj = { - parameters: { - docs: { - source: { - code: ` -// refer to "Default" story code with: -// props: {aria-label: 'Favorite Animal', labelComponent: Favorite Animal, value}`, - }, - }, - }, - render: () => ( - Favorite Animal} - value={exampleOptions[0]} - /> - ), -}; diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx index 99676244a..d22a0fc8e 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/Select.test.tsx @@ -9,9 +9,10 @@ import * as stories from './Select.stories'; const { OpenByDefault, Disabled, - OptionsLeftAligned, OptionsRightAligned, SeparateButtonAndMenuWidth, + EventHandlingOnRenderProp, + EventHandlingOnStandardButton, ...closedStories } = stories; @@ -33,14 +34,16 @@ const exampleOptions = [ ]; describe('', () => { // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access expect(container.querySelector(`input`)).toBeInTheDocument(); }); + + describe('event handling', () => { + it('handles click on .Button', async () => { + const clickHandler = jest.fn(); + const user = userEvent.setup(); + + render( + , + ); + + const openTrigger = await screen.findByRole('button'); + + await user.click(openTrigger); + expect(clickHandler).toHaveBeenCalledTimes(1); + }); + + it('handles click on .ButtonWrapper when using render prop', async () => { + const clickHandler = jest.fn(); + const user = userEvent.setup(); + + render( + , + ); + + const openTrigger = await screen.findByRole('button'); + + await user.click(openTrigger); + expect(clickHandler).toHaveBeenCalledTimes(1); + }); + + it('handles change on + Select + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + , + ); + + const openTrigger = await screen.findByRole('button'); + + // It should only fire change once, after the value is actually modified + await user.click(openTrigger); + expect(changeHandler).toHaveBeenCalledTimes(0); + + // pick the second item + await user.keyboard('{arrowdown}'); + await user.keyboard('{enter}'); + + expect(changeHandler).toHaveBeenCalledTimes(1); + }); + + it('does not call change when + Select + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + , + ); + + const openTrigger = await screen.findByRole('button'); + + // It should only fire change once, after the value is actually modified + await user.click(openTrigger); + expect(changeHandler).toHaveBeenCalledTimes(0); + + // pick the same item + await user.keyboard('{enter}'); + + expect(changeHandler).toHaveBeenCalledTimes(0); + }); + }); }); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index d322eacad..c3f93c0bc 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -1,6 +1,11 @@ import { Listbox } from '@headlessui/react'; import clsx from 'clsx'; -import type { ReactElement, ReactNode, ElementType } from 'react'; +import type { + ReactElement, + ReactNode, + ElementType, + MouseEventHandler, +} from 'react'; import React, { useContext, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -62,8 +67,15 @@ type SelectProps = ExtractProps & * The style of the select. * * Compact renders select trigger button that is only as wide as the content. + * + * This is **deprecated**. Please use utility classes to adjust the component width. + * @deprecated */ variant?: VariantType; + /** + * Visible text label for the component. + */ + label?: string; }; type SelectOption = { @@ -101,6 +113,10 @@ type SelectButtonProps = { * Indicates state of the select, used to style the button. */ isOpen?: boolean; + /** + * custom click handler for the built-in or wrapper button + */ + onClick?: MouseEventHandler; }; type SelectContextType = PopoverContext & { @@ -145,6 +161,7 @@ export function Select(props: SelectProps) { 'aria-label': ariaLabel, children, className, + label, modifiers = defaultPopoverModifiers, name, onFirstUpdate, @@ -153,15 +170,23 @@ export function Select(props: SelectProps) { placement = 'bottom-start', strategy, variant, + onChange: theirOnChange, ...other } = props; - if (process.env.NODE_ENV !== 'production' && !name && showNameWarning) { - console.warn( - "%c`Select` won't render a form field unless you include a `name` prop.\n\n See https://headlessui.com/react/listbox#using-with-html-forms for more information", - 'font-weight: bold', - ); - showNameWarning = false; + if (process.env.NODE_ENV !== 'production') { + const childrenHaveLabel = + children && childrenHaveLabelComponent(children as ReactNode); + if (!props['aria-label'] && !props.label && !childrenHaveLabel) { + throw new Error('You must provide a visible label or `aria-label`.'); + } + if (!name && showNameWarning) { + console.warn( + "%c`Select` won't render a form field unless you include a `name` prop.\n\n See https://headlessui.com/react/listbox#using-with-html-forms for more information", + 'font-weight: bold', + ); + showNameWarning = false; + } } // Translate old optionsAlign to placement values @@ -180,15 +205,14 @@ export function Select(props: SelectProps) { { placement: optionsPlacement, modifiers, strategy, onFirstUpdate }, ); - const compact = variant === 'compact'; + // Create a new value to track the internal state of Listbox. Added to work around + // behavior inherited from HeadlessUI where it will fire onChange even if there is no change + // Adding to support behavior synced to how Compact story renders snapshot 1`] = ` +exports[` Default story renders snapshot 1`] = ` -
+ Favorite Animal + +
`; -exports[` Generated Snapshots UsingFunctionProps story renders snapshot 1`] = `
-
- -
`; -exports[` Generated Snapshots WithFieldName story renders snapshot 1`] = `
+
+ +
`; -exports[` Generated Snapshots WithSelectedOption story renders snapshot 1`] = `
+
+ +
`; -exports[` Generated Snapshots WithStandardButton story renders snapshot 1`] = `
@@ -341,23 +328,23 @@ exports[`