diff --git a/.yarn/cache/@carbon-ibm-products-styles-npm-2.20.1-1f210e0807-a069cae6b7.zip b/.yarn/cache/@carbon-ibm-products-styles-npm-2.20.1-1f210e0807-a069cae6b7.zip new file mode 100644 index 00000000000..2f402d6b5a4 Binary files /dev/null and b/.yarn/cache/@carbon-ibm-products-styles-npm-2.20.1-1f210e0807-a069cae6b7.zip differ diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.babelrc b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.babelrc new file mode 100644 index 00000000000..74450eed94b --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.babelrc @@ -0,0 +1,22 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "modules": false, + "targets": [ + "last 1 version", + "Firefox ESR", + "not opera > 0", + "not op_mini > 0", + "not op_mob > 0", + "not android > 0", + "not edge > 0", + "not ie > 0", + "not ie_mob > 0" + ] + } + ] + ], + "plugins": [["@babel/plugin-transform-runtime", { "version": "7.3.0" }]] +} diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.gitignore b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.gitignore new file mode 100644 index 00000000000..d94d6e13e94 --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.gitignore @@ -0,0 +1,22 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +/node_modules + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.cache +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.sassrc b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.sassrc new file mode 100644 index 00000000000..956b9e0a3d8 --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/.sassrc @@ -0,0 +1,6 @@ +{ + "includePaths": [ + "node_modules", + "../../node_modules" + ] +} \ No newline at end of file diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/cdn.html b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/cdn.html new file mode 100644 index 00000000000..22072368242 --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/cdn.html @@ -0,0 +1,78 @@ + + + + + + @carbon/ibmdotcom-web-components example + + + + + + + + + + + + + + +
Side panel content
+
+ + +
+
+ + +
+
+ + + +
+ + +
Subtitle text which can provide more detail on the content being displayed.
+ + + Copy + + ${Settings({ slot: 'icon' })} + + + ${Trashcan({ slot: 'icon' })} + + + + Ghost + +
+ + + \ No newline at end of file diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/index.html b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/index.html new file mode 100644 index 00000000000..c9861dc47b0 --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/index.html @@ -0,0 +1,63 @@ + + + + + + carbon-web-components example + + + + + + + + + + + +
Side panel content
+
+ + +
+
+ + +
+
+ + + +
+ + +
Subtitle text which can provide more detail on the content being displayed.
+ + + Copy + + ${Settings({ slot: 'icon' })} + + + ${Trashcan({ slot: 'icon' })} + + + + Ghost + +
+ + + \ No newline at end of file diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/package.json b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/package.json new file mode 100644 index 00000000000..62007e6f09c --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/package.json @@ -0,0 +1,23 @@ +{ + "name": "carbon-web-components-side-panel-example", + "version": "0.1.0", + "private": true, + "description": "Sample project for getting started with the Web Components from Carbon.", + "license": "Apache-2", + "main": "index.html", + "scripts": { + "build": "parcel build *.html --no-minify --public-url ./", + "clean": "rimraf node_modules dist .cache", + "start": "parcel index.html --port=9000 --no-hmr" + }, + "dependencies": { + "@carbon/styles": "^1.34.0", + "@carbon/web-components": "latest", + "sass": "^1.64.1" + }, + "devDependencies": { + "@babel/core": "^7.0.0-0", + "parcel-bundler": "1.12.3", + "rimraf": "^3.0.2" + } +} diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/sandbox.config.json b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/sandbox.config.json new file mode 100644 index 00000000000..a4df8557d7b --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/sandbox.config.json @@ -0,0 +1,3 @@ +{ + "template": "node" +} diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/src/index.js b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/src/index.js new file mode 100644 index 00000000000..0f30db202da --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/src/index.js @@ -0,0 +1,13 @@ +/** + * @license + * + * Copyright IBM Corp. 2020, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import '@carbon/web-components/es/components/text-input/index.js'; +import '@carbon/web-components/es/components/textarea/index.js'; +import '@carbon/web-components/es/components/button/index.js'; +import '@carbon/web-components/es/components/side-panel/index.js'; diff --git a/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/src/styles.scss b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/src/styles.scss new file mode 100644 index 00000000000..5bde587c9b5 --- /dev/null +++ b/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel/src/styles.scss @@ -0,0 +1,9 @@ +@use '@carbon/styles/scss/reset'; +@use '@carbon/styles/scss/theme'; +@use '@carbon/styles/scss/themes'; + +:root { + @include theme.theme(themes.$white); + background-color: var(--cds-background); + color: var(--cds-text-primary); +} diff --git a/packages/carbon-web-components/package.json b/packages/carbon-web-components/package.json index a1a76d993ce..9b46abe368a 100644 --- a/packages/carbon-web-components/package.json +++ b/packages/carbon-web-components/package.json @@ -68,6 +68,7 @@ ], "dependencies": { "@babel/runtime": "^7.16.3", + "@carbon/ibm-products-styles": "2.20.1", "@carbon/styles": "1.48.1", "flatpickr": "4.6.1", "lit": "^2.7.6", diff --git a/packages/carbon-web-components/src/components/button/button-set.ts b/packages/carbon-web-components/src/components/button/button-set.ts index b12565aa649..3c5b562df9e 100644 --- a/packages/carbon-web-components/src/components/button/button-set.ts +++ b/packages/carbon-web-components/src/components/button/button-set.ts @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2020, 2023 + * Copyright IBM Corp. 2020, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -25,7 +25,7 @@ class CDSButtonSet extends LitElement { * * @private */ - private _handleSlotChange(event: Event) { + protected _handleSlotChange(event: Event) { const childItems = (event.target as HTMLSlotElement) .assignedNodes() .filter((elem) => diff --git a/packages/carbon-web-components/src/components/button/button.scss b/packages/carbon-web-components/src/components/button/button.scss index ee15ec37b2f..22005322b98 100644 --- a/packages/carbon-web-components/src/components/button/button.scss +++ b/packages/carbon-web-components/src/components/button/button.scss @@ -1,5 +1,5 @@ // -// Copyright IBM Corp. 2019, 2023 +// Copyright IBM Corp. 2019, 2024 // // This source code is licensed under the Apache-2.0 license found in the // LICENSE file in the root directory of this source tree. @@ -103,7 +103,8 @@ $css--plex: true !default; outline: none; } -:host(#{$prefix}-button-set) { +:host(#{$prefix}-button-set), +:host(#{$prefix}-side-panel-button-set) { @extend .#{$prefix}--btn-set; ::slotted(#{$prefix}-button) { diff --git a/packages/carbon-web-components/src/components/side-panel/defs.ts b/packages/carbon-web-components/src/components/side-panel/defs.ts new file mode 100644 index 00000000000..bacc45894bb --- /dev/null +++ b/packages/carbon-web-components/src/components/side-panel/defs.ts @@ -0,0 +1,46 @@ +/** + * @license + * + * Copyright IBM Corp. 2023, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Modal size. + */ +export enum SIDE_PANEL_SIZE { + /** + * Extra small size. + */ + EXTRA_SMALL = 'xs', + + /** + * Small size. + */ + SMALL = 'sm', + + /** + * Medium size. + */ + MEDIUM = 'md', + + /** + * Large size. + */ + LARGE = 'lg', + + /** + * 2X-Large size. + */ + EXTRA_EXTRA_LARGE = '2xl', +} + +export enum SIDE_PANEL_PLACEMENT { + /** right / default */ + RIGHT = 'right', + + /** left */ + LEFT = 'left', +} diff --git a/packages/carbon-web-components/src/components/side-panel/index.ts b/packages/carbon-web-components/src/components/side-panel/index.ts new file mode 100644 index 00000000000..0ad92359b73 --- /dev/null +++ b/packages/carbon-web-components/src/components/side-panel/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * + * Copyright IBM Corp. 2023, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import './side-panel'; diff --git a/packages/carbon-web-components/src/components/side-panel/side-panel-button-set.ts b/packages/carbon-web-components/src/components/side-panel/side-panel-button-set.ts new file mode 100644 index 00000000000..da67aa063e6 --- /dev/null +++ b/packages/carbon-web-components/src/components/side-panel/side-panel-button-set.ts @@ -0,0 +1,28 @@ +/** + * @license + * + * Copyright IBM Corp. 2020, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { customElement } from 'lit/decorators.js'; +import { prefix } from '../../globals/settings'; +import CDSButtonSet from '../button/button-set'; + +/** + * Button set. + * + * @element cds-side-panel-button-set + */ +@customElement(`${prefix}-side-panel-button-set`) +class CDSSidePanelButtonSet extends CDSButtonSet { + _handleSlotChange() { + // do not re-order button set + return; + } +} + +/* @__GENERATE_REACT_CUSTOM_ELEMENT_TYPE__ */ +export default CDSSidePanelButtonSet; diff --git a/packages/carbon-web-components/src/components/side-panel/side-panel-story.mdx b/packages/carbon-web-components/src/components/side-panel/side-panel-story.mdx new file mode 100644 index 00000000000..7f37264b6cc --- /dev/null +++ b/packages/carbon-web-components/src/components/side-panel/side-panel-story.mdx @@ -0,0 +1,75 @@ +import { Props, Description } from '@storybook/addon-docs/blocks'; +import { cdnJs, cdnCss } from '../../globals/internal/storybook-cdn'; + +# SidePanel + +> 💡 Check our +> [CodeSandbox](https://codesandbox.io/s/github/carbon-design-system/carbon-for-ibm-dotcom/tree/feat/main/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel) +> example implementation. + +[![Edit carbon-web-components](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/carbon-design-system/carbon-for-ibm-dotcom/tree/feat/main/packages/carbon-web-components/examples/codesandbox/basic/components/side-panel) + +Side panels keep users in-context of a page while performing tasks like navigating, editing, viewing details, or configuring something new. + +## Getting started + +Here's a quick example to get you started. + +### JS (via import) + +```javascript +import '@carbon/web-components/es/components/side-panel/index.js'; +// The following are used for slotted fields +import '@carbon/web-components/es/components/text-input/index.js'; +import '@carbon/web-components/es/components/textarea/index.js'; +import '@carbon/web-components/es/components/button/index.js'; +``` + + + + +### HTML + +```html + + +
Side panel content
+
+ + +
+
+ + +
+
+ + + +
+ + +
Subtitle text which can provide more detail on the content being displayed.
+ + + Copy + + ${Settings({ slot: 'icon' })} + + + ${Trashcan({ slot: 'icon' })} + + + + Ghost + +
+``` + +## `` attributes, properties and events + +Note: For `boolean` attributes, `true` means simply setting the attribute (e.g. +``) and `false` means not setting the attribute (e.g. +`` without `open` attribute). + + diff --git a/packages/carbon-web-components/src/components/side-panel/side-panel-story.ts b/packages/carbon-web-components/src/components/side-panel/side-panel-story.ts new file mode 100644 index 00000000000..73e96082ded --- /dev/null +++ b/packages/carbon-web-components/src/components/side-panel/side-panel-story.ts @@ -0,0 +1,434 @@ +/** + * @license + * + * Copyright IBM Corp. 2023, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { TemplateResult, html } from 'lit'; +import { boolean, select, text } from '@storybook/addon-knobs'; +import '../button/button'; +import { SIDE_PANEL_SIZE } from './side-panel'; +import './index'; +import '../text-input/index'; +import '../textarea/index'; +import storyDocs from './side-panel-story.mdx'; +import { SIDE_PANEL_PLACEMENT } from './defs'; +import Settings from '@carbon/icons/lib/settings/16'; +import Trashcan from '@carbon/icons/lib/trash-can/16'; +import { prefix } from '../../globals/settings'; + +import styles from './story-styles.scss'; +import { BUTTON_KIND } from '../button/button'; +const toggleButton = () => { + document.querySelector(`${prefix}-side-panel`)?.toggleAttribute('open'); +}; + +const sizes = { + // 'default (md)': null, + [`Extra small size (${SIDE_PANEL_SIZE.EXTRA_SMALL})`]: + SIDE_PANEL_SIZE.EXTRA_SMALL, + [`Small size (${SIDE_PANEL_SIZE.SMALL})`]: SIDE_PANEL_SIZE.SMALL, + [`Medium size (default) (${SIDE_PANEL_SIZE.MEDIUM})`]: SIDE_PANEL_SIZE.MEDIUM, + [`Large size (${SIDE_PANEL_SIZE.LARGE})`]: SIDE_PANEL_SIZE.LARGE, + [`Extra Extra Large size (${SIDE_PANEL_SIZE.EXTRA_EXTRA_LARGE})`]: + SIDE_PANEL_SIZE.EXTRA_EXTRA_LARGE, +}; + +const placements = { + // 'default (right)': null, + left: SIDE_PANEL_PLACEMENT.LEFT, + 'right (default)': SIDE_PANEL_PLACEMENT.RIGHT, +}; + +const contents = { + Empty: 0, + 'Brief content': 1, + 'Longer content': 2, +}; + +const storyPrefix = 'side-panel-stories__'; + +const getContent = (index) => { + switch (index) { + case 1: + return html` + +
Section
+ + + `; + case 2: + return html` +
Section
+
+ + +
+
+ + +
+
+ + + +
`; + default: + return null; + } +}; + +const labels = { + 'No label': 0, + 'Shorter label': 1, + 'Longer label': 2, +}; + +const getLabel = (index) => { + switch (index) { + case 1: + return 'A short label'; + case 2: + return 'A longer label that might go on for a little bit'; + default: + return ''; + } +}; + +const subtitles = { + 'No subtitle': 0, + 'Short subtitle': 1, + 'Longer subtitle': 2, +}; +const getSubTitle = (index) => { + switch (index) { + case 1: + return html`
This is your subtitle slot.
`; + case 2: + return html`
+ I am your subtitle slot for adding detail that can be + one or two lines. +
`; + default: + return null; + } +}; + +const actionToolbarItems = { + 'No action toolbar': 0, + 'With action toolbar': 1, +}; + +const getActionToolbarItems = (index) => { + switch (index) { + case 1: + return html` + Copy + + ${Settings({ slot: 'icon' })} + + + ${Trashcan({ slot: 'icon' })} + + `; + default: + return null; + } +}; + +const actionItems = { + 'No actions': 0, + 'One button': 1, + 'Two buttons with ghost': 2, + 'Two buttons with danger': 3, + 'Three buttons with ghost': 4, + 'Three buttons with danger': 5, +}; + +// TODO: There are problems switching this +const getActionItems = (index) => { + switch (index) { + case 1: + return html`Primary`; + case 2: + return html` + Ghost + Primary + `; + case 3: + return html` Danger + Primary`; + case 4: + return html` Ghost + Secondary + Primary`; + case 5: + return html`Danger + Secondary + Primary`; + default: + return null; + } +}; + +const slugs = { + 'No Slug': 0, + 'With Slug': 1, +}; + +const getSlug = (index) => { + switch (index) { + case 1: + return html` +
+

AI Explained

+

84%

+

Confidence score

+

+ Lorem ipsum dolor sit amet, di os consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut fsil labore et dolore magna aliqua. +

+
+

Model type

+

Foundation model

+
+
`; + default: + return null; + } +}; + +export default { + title: 'Experimental/SidePanel', + decorators: [(story) => html` ${story()} `], + parameters: { + ...storyDocs.parameters, + }, +}; + +const DefaultTemplate = (argsIn) => { + const args = { + actionItems: getActionItems(select('Slot (actions)', actionItems, 1)), + actionToolbarItems: getActionToolbarItems( + select('Slot (action-toolbar)', actionToolbarItems, 0) + ), + animateTitle: boolean('animate-title (Title animates on scroll)', true), + class: text('class', 'a-user-class'), + condensedActions: boolean('condensed-actions', false), + content: getContent(select('Slot (default), panel contents', contents, 2)), + includeOverlay: boolean('include-overlay', true), + label: getLabel(select('label', labels, 2)), + open: boolean('open', false), + placement: select('placement', placements, SIDE_PANEL_PLACEMENT.RIGHT), + preventCloseOnClickOutside: boolean( + 'prevent-close-on-click-outside', + false + ), + selectorPageContent: text( + 'selector-page-content', + '#page-content-selector' + ), + selectorInitialFocus: text('selector-initial-focus', ''), + size: select('size', sizes, SIDE_PANEL_SIZE.MEDIUM), + slideIn: boolean('slide-in', false), + slug: getSlug(select('slug (AI slug)', slugs, 0)), + subtitle: getSubTitle(select('Slot (subtitle)', subtitles, 1)), + title: text( + 'title', + 'This title is testing a very long title to see how this behaves with a longer title. It needs to be long enough to trigger overflow when collapsed.' + ), + + ...(argsIn?.['cds-side-panel'] ?? {}), + }; + + return html` +
+
+
+ Toggle side-panel +
+
+ + + ${args.content} + + + ${args.subtitle} + + + ${args.actionToolbarItems} + + + ${args.actionItems} + + + ${args.slug} + + `; +}; + +type TemplateType = { + (args: any): TemplateResult<1>; + parameters: { knobs: { [key: string]: any } }; +}; + +export const SlideOver = DefaultTemplate.bind({}) as TemplateType; +SlideOver.parameters = { + ...storyDocs.parameters, + knobs: { + 'cds-side-panel': () => ({}), + }, +}; + +export const SlideIn = DefaultTemplate.bind({}) as TemplateType; +SlideIn.parameters = { + ...storyDocs.parameters, + knobs: { + 'cds-side-panel': () => ({ + slideIn: boolean('slide-in', true), + }), + }, +}; + +export const WithActionToolbar = DefaultTemplate.bind({}) as TemplateType; +WithActionToolbar.parameters = { + ...storyDocs.parameters, + knobs: { + 'cds-side-panel': () => ({ + actionToolbarItems: getActionToolbarItems( + select('Action toolbar slot', actionToolbarItems, 1) + ), + }), + }, +}; + +export const SpecifyElementToHaveFocus = DefaultTemplate.bind( + {} +) as TemplateType; +SpecifyElementToHaveFocus.parameters = { + ...storyDocs.parameters, + knobs: { + 'cds-side-panel': () => ({ + focusSelector: text( + 'selector-primary-focus', + '#side-panel-story-text-input-a' + ), + label: getLabel(select('label', labels, 0)), + }), + }, +}; + +export const WithStaticTitle = DefaultTemplate.bind({}) as TemplateType; +WithStaticTitle.parameters = { + ...storyDocs.parameters, + knobs: { + 'cds-side-panel': () => ({ + animateTitle: boolean('animate-title (Title animates on scroll)', false), + label: getLabel(select('label', labels, 0)), + }), + }, +}; + +export const WithStaticTitleAndActionToolbar = DefaultTemplate.bind( + {} +) as TemplateType; +WithStaticTitleAndActionToolbar.parameters = { + ...storyDocs.parameters, + knobs: { + 'cds-side-panel': () => ({ + actionToolbarItems: getActionToolbarItems( + select('Action toolbar slot', actionToolbarItems, 1) + ), + animateTitle: boolean('animate-title (Title animates on scroll)', false), + label: getLabel(select('label', labels, 0)), + }), + }, +}; + +export const WithoutTitle = DefaultTemplate.bind({}) as TemplateType; +WithoutTitle.parameters = { + ...storyDocs.parameters, + knobs: { + 'cds-side-panel': () => ({ + label: getLabel(select('label', labels, 0)), + title: text('title', ''), + }), + }, +}; diff --git a/packages/carbon-web-components/src/components/side-panel/side-panel.scss b/packages/carbon-web-components/src/components/side-panel/side-panel.scss new file mode 100644 index 00000000000..a17bb65cd8b --- /dev/null +++ b/packages/carbon-web-components/src/components/side-panel/side-panel.scss @@ -0,0 +1,386 @@ +/* +* Copyright IBM Corp. 2023, 2024 +* +* This source code is licensed under the Apache-2.0 license found in the +* LICENSE file in the root directory of this source tree. +*/ + +$css--plex: true !default; + +/* Other Carbon settings. */ +@use '@carbon/styles/scss/reset'; +@use '@carbon/styles/scss/config' as *; +@use '@carbon/styles/scss/motion' as *; +@use '@carbon/styles/scss/spacing' as *; +@use '@carbon/styles/scss/theme' as *; +@use '@carbon/styles/scss/utilities/ai-gradient' as *; +@use 'sass:map'; + +$pkg-prefix: 'cds'; + +@use '@carbon/ibm-products-styles/scss/config' with ( + $pkg-prefix: #{$pkg-prefix} +); +@use '@carbon/ibm-products-styles/scss/components/ActionSet/index' as *; +@use '@carbon/ibm-products-styles/scss/components/SidePanel/index' as *; +@use '@carbon/ibm-products-styles/scss/components/SidePanel/side-panel-variables' + as spv; + +$block-class: #{$pkg-prefix}--side-panel; +$block-class-action-set: #{$pkg-prefix}--action-set; + +:host(#{$prefix}-side-panel) { + /* Replaces use of Framer for slide in / out animation */ + --#{$block-class}--displaced: 100%; + + * { + /* not sure why things are coming out as content-box */ + box-sizing: border-box; + } + + .#{$block-class} { + @extend .#{$block-class}__container; + + /* for actions container query - note this changes the size calculation */ + container-name: side-panel; + container-type: inline-size; + + &[placement='left'] { + --#{$block-class}--displaced: -100%; + } + + &[opening] { + transform: translateX(var(--#{$block-class}--displaced)); + } + + &[open] { + transform: translateX(0); + transition: all $duration-moderate-02 motion(standard, productive); + } + + @media screen and (prefers-reduced-motion: reduce) { + &[open] { + transition: none; + } + } + + &[closing] { + transform: translateX(var(--#{$block-class}--displaced)); + transition: all $duration-moderate-01 motion(exit, productive); + } + + @media screen and (prefers-reduced-motion: reduce) { + &[closing] { + transition: none; + } + } + + &[placement='right'] { + @extend .#{$block-class}__container-right-placement; + + /* remove if https://github.com/carbon-design-system/ibm-products/pull/3983 merged */ + border-inline-end: 1px solid $border-subtle-02; + inset-inline-end: 0; + } + + /* stylelint-disable-next-line no-duplicate-selectors -- disabled to keep close to 'right' setting */ + &[placement='left'] { + @extend .#{$block-class}__container-left-placement; + + /* remove if https://github.com/carbon-design-system/ibm-products/pull/3983 merged */ + border-inline-end: 1px solid $border-subtle-02; + inset-inline-start: 0; + } + + @each $size, $size_value in spv.$side-panel-sizes { + &[size='#{$size}'] { + @extend .#{$block-class}__container--#{$size}; + } + } + + &:not([overlay]) { + @extend .#{$block-class}__container-without-overlay; + } + } + + .#{$block-class}__overlay { + @extend .#{$block-class}__overlay; + + &[opening] { + opacity: 0; + } + @media screen and (prefers-reduced-motion: reduce) { + &[open] { + opacity: 1; + transition: none; + } + } + + &[open] { + opacity: 1; + transition: all $duration-moderate-02 motion(standard, productive); + } + @media screen and (prefers-reduced-motion: reduce) { + &[closing] { + opacity: 0; + transition: none; + } + } + + &[closing] { + opacity: 0; + transition: all $duration-moderate-01 motion(exit, productive); + } + } + + .#{$block-class}__title-container { + @extend .#{$block-class}__title-container; + + &::before { + z-index: 99; /* must be higher than action toolbar */ + } + + &[detail-step] { + @extend .#{$block-class}__on-detail-step; + } + + &:not([has-title]) { + @extend .#{$block-class}__title-container-without-title; + } + + &[detail-step]:not([has-title]) { + @extend .#{$block-class}__on-detail-step-without-title; + } + + &[no-title-animation] { + @extend .#{$block-class}__title-container--no-title-animation; + // inset-block-start: 0; + } + + &[reduced-motion] { + @extend .#{$block-class}__title-container--reduced-motion; + } + + &[has-action-toolbar] { + margin-block-end: $spacing-03; + padding-block-end: 0; + } + } + + .#{$block-class} + .#{$block-class}__title-container[has-action-toolbar]::before { + content: initial; /* remove border below */ + } + + .#{$block-class}__nav-back-button { + @extend .#{$block-class}__navigation-back-button; + } + + .#{$block-class}__collapsed-title { + @extend .#{$block-class}__collapsed-title-text; + } + + .#{$block-class}__title { + @extend .#{$block-class}__title-text; + } + + .#{$block-class} .#{$block-class}__title { + margin-block-end: calc(-1 * var(--#{$block-class}--reduce-titles-by)); + } + + .#{$block-class}__slug-and-close { + @extend .#{$block-class}__slug-and-close; + } + + .#{$block-class}__close-button { + @extend .#{$block-class}__close-button; + } + + .#{$block-class}__label { + @extend .#{$block-class}__label-text; + + // opacity: var(--#{$block-class}--subtitle-opacity, 1); + } + + .#{$block-class}__subtitle { + @extend .#{$block-class}__subtitle-text; + + &[hidden] { + @extend .#{$prefix}--visually-hidden; + } + + &[no-title-animation] { + @extend .#{$block-class}__subtitle-text-no-animation; + } + + &[no-title-animation][no-action-toolbar] { + /* not working @extend .#{$block-class}__subtitle-text-no-animation-no-action-toolbar; */ + border-block-end: 1px solid $layer-active-01; + margin-block-end: $spacing-05; + } + + &[no-title] { + @extend .#{$block-class}__subtitle-without-title; + } + } + + .#{$block-class}__action-toolbar { + @extend .#{$block-class}__action-toolbar; + + margin-block-end: 0; + padding-inline-start: 0; + + &[hidden] { + @extend .#{$prefix}--visually-hidden; + } + + &[no-title-animation] { + @extend .#{$block-class}__action-toolbar-no-animation; + } + + &::after { + position: absolute; + background-color: $border-subtle-02; + block-size: 1px; + content: ''; + inline-size: 100%; + inset-block-end: 0; + inset-inline-start: 0; + opacity: var(--#{$block-class}--divider-opacity); + } + } + + .#{$block-class} .#{$block-class}__action-toolbar[hidden] { + @extend .#{$prefix}--visually-hidden; + } + + .#{$block-class}__inner-content { + @extend .#{$block-class}__inner-content; + + block-size: calc( + 100% - var(--#{$block-class}--actions-height, $spacing-09) + ); + + &[no-title-animation] { + @extend .#{$block-class}__static-inner-content; + } + + &[has-actions] { + @extend .#{$block-class}__inner-content-with-actions; + } + + &[no-title-animation]:not([has-actions]) { + @extend .#{$block-class}__static-inner-content-no-actions; + } + } + + .#{$block-class}[has-slug] .#{$block-class}__inner-content { + @include callout-gradient('default'); + } + + .#{$block-class}__body-content { + @extend .#{$block-class}__body-content; + } + + @mixin actions-placement() { + --flex-direction: row; + /* non carbon for IBM Products adjustment */ + flex-direction: var(--flex-direction); + + /* stylelint-disable-next-line selector-type-no-unknown */ + ::slotted(cds-button) { + flex: 0 1 25%; + max-inline-size: to-rem(232px); + } + + /* stylelint-disable-next-line selector-type-no-unknown */ + ::slotted(cds-button[kind='ghost']) { + flex: 1 1 25%; + max-inline-size: none; + } + + // -1 in @container query is for 1px left border + @container (width <= #{map.get(spv.$side-panel-sizes, lg)}) { + /* stylelint-disable-next-line selector-type-no-unknown */ + &:not([actions-multiple='triple']) ::slotted(cds-button) { + // double and single on lg use 50% + flex: 0 1 50%; + max-inline-size: none; + } + } + + // -1 in @container query is for 1px left border + @container (width <= #{map.get(spv.$side-panel-sizes, md)}) { + // md is 50% for two and 100% for one + // column for triple + /* stylelint-disable-next-line selector-type-no-unknown */ + &[actions-multiple] ::slotted(cds-button) { + flex: 0 0 100%; + max-inline-size: none; + } + + /* stylelint-disable-next-line selector-type-no-unknown */ + &[actions-multiple='double'] ::slotted(cds-button) { + flex: 0 1 50%; + max-inline-size: none; + } + + &[actions-multiple='triple'] { + --flex-direction: column; + } + } + + // -1 in @container query is for 1px left border + @container (width <= #{map.get(spv.$side-panel-sizes, sm)}) { + --flex-direction: column; + + /* stylelint-disable-next-line selector-type-no-unknown */ + &[actions-multiple] ::slotted(cds-button) { + flex: 0 0 100%; + max-inline-size: none; + } + } + } + + .#{$block-class}__actions { + @extend .#{$block-class}__actions-container; + @extend .#{$block-class-action-set}; + @include actions-placement(); + + display: flex; + inline-size: 100%; + + /* stylelint-disable-next-line selector-type-no-unknown */ + ::slotted(cds-button) { + @extend .#{$block-class-action-set}__action-button; + } + + &[hidden] { + @extend .#{$prefix}--visually-hidden; + + display: none; + } + + &[condensed] { + @extend .#{$block-class}__actions-container-condensed; + } + + $multiples: 'single' 'double' 'triple'; + @each $m in $multiples { + &[actions-multiple='#{$m}'] { + @extend .#{$prefix}--action-set--row-#{$m}; + } + } + + $sizes: 'xs' 'sm'; + @each $s in $sizes { + &[size='#{$s}'] { + @extend .#{$block-class-action-set}--#{$s} !optional; + } + } + } + + .sentinel { + @extend .#{$prefix}--visually-hidden; + } +} diff --git a/packages/carbon-web-components/src/components/side-panel/side-panel.ts b/packages/carbon-web-components/src/components/side-panel/side-panel.ts new file mode 100644 index 00000000000..a5867c04bbc --- /dev/null +++ b/packages/carbon-web-components/src/components/side-panel/side-panel.ts @@ -0,0 +1,1045 @@ +/** + * @license + * + * Copyright IBM Corp. 2023, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { LitElement, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { prefix } from '../../globals/settings'; +import HostListener from '../../globals/decorators/host-listener'; +import HostListenerMixin from '../../globals/mixins/host-listener'; +import { SIDE_PANEL_SIZE, SIDE_PANEL_PLACEMENT } from './defs'; +import styles from './side-panel.scss'; +import { selectorTabbable } from '../../globals/settings'; +import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; +import ArrowLeft16 from '@carbon/icons/lib/arrow--left/16'; +import Close20 from '@carbon/icons/lib/close/20'; +import { moderate02 } from '@carbon/motion'; +import '../button/index'; +import '../layer/index'; +import Handle from '../../globals/internal/handle'; +import './side-panel-button-set'; + +export { SIDE_PANEL_SIZE }; + +// eslint-disable-next-line no-bitwise +const PRECEDING = + Node.DOCUMENT_POSITION_PRECEDING | Node.DOCUMENT_POSITION_CONTAINS; +// eslint-disable-next-line no-bitwise +const FOLLOWING = + Node.DOCUMENT_POSITION_FOLLOWING | Node.DOCUMENT_POSITION_CONTAINED_BY; + +const blockClass = `${prefix}--side-panel`; +const blockClassActionSet = `${prefix}--action-set`; + +/** + * Observes resize of the given element with the given resize observer. + * + * @param observer The resize observer. + * @param elem The element to observe the resize. + */ +const observeResize = (observer: ResizeObserver, elem: Element) => { + if (!elem) { + return null; + } + observer.observe(elem); + return { + release() { + observer.unobserve(elem); + return null; + }, + } as Handle; +}; + +/** + * Tries to focus on the given elements and bails out if one of them is successful. + * + * @param elems The elements. + * @param reverse `true` to go through the list in reverse order. + * @returns `true` if one of the attempts is successful, `false` otherwise. + */ +function tryFocusElems(elems: NodeListOf, reverse: boolean) { + if (!reverse) { + for (let i = 0; i < elems.length; ++i) { + const elem = elems[i]; + elem.focus(); + if (elem.ownerDocument!.activeElement === elem) { + return true; + } + } + } else { + for (let i = elems.length - 1; i >= 0; --i) { + const elem = elems[i]; + elem.focus(); + if (elem.ownerDocument!.activeElement === elem) { + return true; + } + } + } + return false; +} + +/** + * SidePanel. + * + * @element cds-side-panel + * @csspart dialog The dialog. + * @fires cds-side-panel-beingclosed + * The custom event fired before this side-panel is being closed upon a user gesture. + * Cancellation of this event stops the user-initiated action of closing this side-panel. + * @fires cds-side-panel-closed - The custom event fired after this side-panel is closed upon a user gesture. + */ +@customElement(`${prefix}-side-panel`) +class CDSSidePanel extends HostListenerMixin(LitElement) { + /** + * The handle for observing resize of the parent element of this element. + */ + private _hObserveResize: Handle | null = null; + + /** + * The element that had focus before this side-panel gets open. + */ + private _launcher: Element | null = null; + + /** + * Node to track focus going outside of side-panel content. + */ + @query('#start-sentinel') + private _startSentinelNode!: HTMLAnchorElement; + + /** + * Node to track focus going outside of side-panel content. + */ + @query('#end-sentinel') + private _endSentinelNode!: HTMLAnchorElement; + + /** + * Node to track side panel. + */ + @query(`.${blockClass}`) + private _sidePanel!: HTMLDivElement; + + /** + * Node to track size of actions + */ + @query(`.${blockClass}__actions`) + private _actions!: HTMLElement; + + @query(`.${blockClass}__label`) + private _label!: HTMLElement; + + @query(`.${blockClass}__title-container`) + private _titleContainer!: HTMLElement; + + @query(`.${blockClass}__title`) + private _title!: HTMLElement; + + @query(`.${blockClass}__subtitle`) + private _subtitle!: HTMLElement; + + @query(`.${blockClass}__action-toolbar`) + private _actionToolbar!: HTMLElement; + + @query(`.${blockClass}__inner-content`) + private _innerContent!: HTMLElement; + + @query(`.${blockClass}__body-content`) + private _bodyContent!: HTMLElement; + + @state() + _isOpen = false; + + @state() + _containerScrollTop = -16; + + @state() + _hasSubtitle = false; + + @state() + _hasSlug = false; + + @state() + _hasActionToolbar = false; + + @state() + _actionsCount = 0; + + @state() + _slugCloseSize = 'sm'; + + /** + * Handles `blur` event on this element. + * + * @param event The event. + * @param event.target The event target. + * @param event.relatedTarget The event relatedTarget. + */ + @HostListener('shadowRoot:focusout') + // @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to + private _handleBlur = async ({ target, relatedTarget }: FocusEvent) => { + const { + // condensedActions, + open, + _startSentinelNode: startSentinelNode, + _endSentinelNode: endSentinelNode, + } = this; + + const oldContains = target !== this && this.contains(target as Node); + const currentContains = + relatedTarget !== this && + (this.contains(relatedTarget as Node) || + (this.shadowRoot?.contains(relatedTarget as Node) && + relatedTarget !== (startSentinelNode as Node) && + relatedTarget !== (endSentinelNode as Node))); + + // Performs focus wrapping if _all_ of the following is met: + // * This side-panel is open + // * The viewport still has focus + // * SidePanel body used to have focus but no longer has focus + const { selectorTabbable: selectorTabbableForSidePanel } = this + .constructor as typeof CDSSidePanel; + + if (open && relatedTarget && oldContains && !currentContains) { + const comparisonResult = (target as Node).compareDocumentPosition( + relatedTarget as Node + ); + // eslint-disable-next-line no-bitwise + if (relatedTarget === startSentinelNode || comparisonResult & PRECEDING) { + await (this.constructor as typeof CDSSidePanel)._delay(); + if ( + !tryFocusElems( + this.querySelectorAll(selectorTabbableForSidePanel), + true + ) && + relatedTarget !== this + ) { + this.focus(); + } + } + // eslint-disable-next-line no-bitwise + else if ( + relatedTarget === endSentinelNode || + comparisonResult & FOLLOWING + ) { + await (this.constructor as typeof CDSSidePanel)._delay(); + if ( + !tryFocusElems( + this.querySelectorAll(selectorTabbableForSidePanel), + true + ) + ) { + this.focus(); + } + } + } + }; + + @HostListener('document:keydown') + // @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to + private _handleKeydown = ({ key, target }: KeyboardEvent) => { + if (key === 'Esc' || key === 'Escape') { + this._handleUserInitiatedClose(target); + } + }; + + private _reducedMotion = + typeof window !== 'undefined' && window?.matchMedia + ? window.matchMedia('(prefers-reduced-motion: reduce)') + : { matches: true }; + + /** + * Handles `click` event on the side-panel container. + * + * @param event The event. + */ + private _handleClickOnOverlay(event: MouseEvent) { + if (!this.preventCloseOnClickOutside) { + this._handleUserInitiatedClose(event.target); + } + } + + /** + * Handles `click` event on the side-panel container. + * + * @param event The event. + */ + private _handleCloseClick(event: MouseEvent) { + this._handleUserInitiatedClose(event.target); + } + + /** + * Handles user-initiated close request of this side-panel. + * + * @param triggeredBy The element that triggered this close request. + */ + private _handleUserInitiatedClose(triggeredBy: EventTarget | null) { + if (this.open) { + const init = { + bubbles: true, + cancelable: true, + composed: true, + detail: { + triggeredBy, + }, + }; + if ( + this.dispatchEvent( + new CustomEvent( + (this.constructor as typeof CDSSidePanel).eventBeforeClose, + init + ) + ) + ) { + this.open = false; + this.dispatchEvent( + new CustomEvent( + (this.constructor as typeof CDSSidePanel).eventClose, + init + ) + ); + } + } + } + + private _handleNavigateBack(triggeredBy: EventTarget | null) { + this.dispatchEvent( + new CustomEvent( + (this.constructor as typeof CDSSidePanel).eventNavigateBack, + { + composed: true, + detail: { + triggeredBy, + }, + } + ) + ); + } + + private _checkUpdateIconButtonSizes = () => { + const slug = this.querySelector('cds-slug'); + const otherButtons = this?.shadowRoot?.querySelectorAll( + '#nav-back-button, #close-button' + ); + + let iconButtonSize = 'sm'; + + if (slug || otherButtons?.length) { + const actions = this?.querySelectorAll?.('cds-button[slot="actions"]'); + + if (actions?.length && /l/.test(this.size)) { + iconButtonSize = 'md'; + } + } + + if (slug) { + slug?.setAttribute('size', iconButtonSize); + } + + if (otherButtons) { + [...otherButtons].forEach((btn) => { + btn.setAttribute('size', iconButtonSize); + }); + } + }; + + private _handleSlugChange(e: Event) { + this._checkUpdateIconButtonSizes(); + const childItems = (e.target as HTMLSlotElement).assignedNodes(); + + this._hasSlug = childItems.length > 0; + } + + private _handleSubtitleChange(e: Event) { + const target = e.target as HTMLSlotElement; + const subtitle = target?.assignedNodes(); + + this._hasSubtitle = subtitle.length > 0; + } + + // eslint-disable-next-line class-methods-use-this + private _handleActionToolbarChange(e: Event) { + const target = e.target as HTMLSlotElement; + const toolbarActions = target?.assignedElements(); + + this._hasActionToolbar = toolbarActions && toolbarActions.length > 0; + + if (this._hasActionToolbar) { + for (const toolbarAction of toolbarActions) { + // toolbar actions size should always be sm + toolbarAction.setAttribute('size', 'sm'); + } + } + } + + private _checkUpdateBottomPadding = () => { + const actionHeightPx = this._actions?.offsetHeight + 16; // add additional 1rem spacing to bottom padding + const actionsHeight = `${Math.round(actionHeightPx / 16)}rem`; + + this._sidePanel.style?.setProperty( + `--${blockClass}--content-bottom-padding`, + actionsHeight + ); + }; + + private _handleActionsChange(e: Event) { + const target = e.target as HTMLSlotElement; + const actions = target?.assignedElements(); + + // update slug size + this._checkUpdateIconButtonSizes(); + + const actionsCount = actions?.length ?? 0; + if (actionsCount === 0) { + return; + } else if (actionsCount > 3) { + this._actionsCount = 3; + console.warn(`Too many side-panel actions, max 3.`); + } else { + this._actionsCount = actionsCount; + } + + for (let i = 0; i < actionsCount; i++) { + if (i > 3) { + // hide excessive side panel actions + actions[i].setAttribute('hidden', ''); + actions[i].setAttribute( + 'data-actions-limit-3-exceeded', + `${actions.length}` + ); + } else { + actions[i].setAttribute('size', this.condensedActions ? 'lg' : 'xl'); + actions[i].classList.add(`${blockClassActionSet}__action-button`); + } + } + + setTimeout(() => { + // update after the updates above are applied + this._checkUpdateBottomPadding(); + }, 1); + } + + /** + * The `ResizeObserver` instance for observing element resizes for re-positioning floating menu position. + */ + // TODO: Wait for `.d.ts` update to support `ResizeObserver` + // @ts-ignore + private _resizeObserver = new ResizeObserver(() => { + if (this._sidePanel) { + this._checkUpdateBottomPadding(); + } + }); + + private _measurements: any = {}; + + private _setMeasuredCustomProperties = async (reason, scrollY = 0) => { + await this.updateComplete; + + if (!this._sidePanel || (!this.open && !this._innerContent)) { + return; + } + + await (this.constructor as typeof CDSSidePanel)._delay(); // measure after brief delay for render + this._measurements.subtitleHeight = this._subtitle?.offsetHeight || 0; // set default subtitle height if a subtitle is not provided to enable scrolling animation + this._sidePanel?.style.setProperty( + `--${blockClass}--subtitle-container-height`, + `${this._measurements.subtitleHeight}px` + ); + + if (reason !== 'scroll') { + this._measurements.panelHeight = this._sidePanel?.offsetHeight || 0; + this._measurements.scrollSectionHeight = + this._bodyContent?.offsetHeight || 0; + this._measurements.titleContainerHeight = + this._titleContainer?.offsetHeight || 0; + this._measurements.titleHeight = this._title?.offsetHeight || 0; + this._measurements.labelHeight = this._label?.offsetHeight || 0; + this._measurements.totalScrollingHeight = + this._measurements.titleContainerHeight + + this._measurements.subtitleHeight + + this._measurements.scrollSectionHeight; + this._measurements.actionToolbarHeight = + this?._actionToolbar?.offsetHeight || 0; + + this._sidePanel?.style.setProperty( + `--${blockClass}--title-text-height`, + this.animateTitle ? '0' : `${this._measurements.titleHeight + 24}px` + ); + + this._sidePanel.style.setProperty( + `--${blockClass}--action-bar-container-height`, + this.animateTitle ? '0' : `${this._measurements.actionToolbarHeight}px` + ); + + this._sidePanel.style.setProperty( + `--${blockClass}--label-text-height`, + `${this._measurements.labelHeight}px` + ); + if (this.animateTitle) { + this._measurements.titlePaddingTop = parseInt( + (this._titleContainer && + window && + window?.getComputedStyle?.(this._titleContainer)?.[ + 'padding-top' + ]) ?? + '0', + 10 + ); + + this._measurements.transitionDistance = + -1 * + Math.max( + this._measurements.titleHeight + + this._measurements.actionToolbarHeight + + this._measurements.titlePaddingTop - + this._measurements.titleContainerHeight, + this._innerContent?.offsetHeight - this._innerContent?.scrollHeight + ); + + // if the difference between the total scrolling height and the panel height is less than + // the subtitleElement height OR if the subtitle element height is 0, use that difference + // as the length of the scroll animation (otherwise the animation will not be able to complete + // because there is not enough scrolling distance to complete it). + this._measurements.subtitleHeight = + this._measurements.totalScrollingHeight - + this._measurements.panelHeight < + this._measurements.subtitleHeight + ? this._measurements.totalScrollingHeight - + this._measurements.panelHeight + : this._measurements.subtitleHeight === 0 + ? 16 + : this._measurements.subtitleHeight; + this._measurements.subtitleHeight = + this._measurements.subtitleHeight < 0 + ? this._innerContent?.scrollHeight - + this._innerContent?.clientHeight + : this._measurements.subtitleHeight; + + this._sidePanel.style.setProperty( + `--${blockClass}--action-bar-container-height`, + `${this._measurements.actionToolbarHeight || 0}px` + ); + + this._sidePanel.style.setProperty( + `--${blockClass}--title-height`, + `${this._measurements.titleHeight + 16}px` + ); + } + } else { + if (this.animateTitle) { + const scrollAnimationProgress = + this._measurements.transitionDistance && + this._measurements.transitionDistance > scrollY + ? scrollY / this._measurements.transitionDistance + : 1; + + this._sidePanel.style.setProperty( + `--${blockClass}--scroll-y`, + `${scrollY}px` + ); + + const scrolled = scrollY > 0; + + this._sidePanel.style.setProperty( + `--${blockClass}--subtitle-opacity`, + !scrolled + ? '1' + : `${ + 1 - + Math.min(this._measurements.transitionDistance, scrollY) / + this._measurements.transitionDistance + }` + ); + + this._sidePanel.style.setProperty( + `--${blockClass}--divider-opacity`, + !scrolled ? '0' : `${scrollAnimationProgress}` + ); + + this._sidePanel.style.setProperty( + `--${blockClass}--title-y-position`, + !scrolled + ? '0rem' + : `${-Math.abs( + Math.min(1, scrollY / this._measurements.subtitleHeight) + )}rem` + ); + + this._sidePanel.style.setProperty( + `--${blockClass}--collapsed-title-y-position`, + !scrolled + ? '1rem' + : `${ + Math.max(0, this._measurements.subtitleHeight - scrollY) / + this._measurements.subtitleHeight + }rem` + ); + + this._sidePanel.style.setProperty( + `--${blockClass}--title-container-height`, + !scrolled ? '0px' : `${this._measurements.titleContainerHeight}px` + ); + + const reduceTitleContainerHeightAmount = + ((this._measurements.labelHeight * scrollAnimationProgress) / + this._measurements.titleContainerHeight) * + 100; + + this._sidePanel.style.setProperty( + `--${blockClass}--reduce-titles-by`, + !scrolled && !this.animateTitle + ? '0px' + : `${Math.trunc(reduceTitleContainerHeightAmount)}px` + ); + } else { + this._sidePanel.style.setProperty( + `--${blockClass}--reduce-titles-by`, + '0px' + ); + } + } + }; + + private _scrollObserver = (event) => { + this._setMeasuredCustomProperties('scroll', event.target.scrollTop); + }; + + /** + * Determines if the title will animate on scroll + */ + @property({ reflect: true, attribute: 'animate-title', type: Boolean }) + animateTitle = true; + + /** + * Sets the close button icon description + */ + @property({ reflect: true, attribute: 'close-icon-description' }) + closeIconDescription = 'Close'; + + /** + * Determines whether the side panel should render the condensed version (affects action buttons primarily) + */ + @property({ type: Boolean, reflect: true, attribute: 'condensed-actions' }) + condensedActions = false; + + /** + * Sets the current step of the side panel + */ + @property({ reflect: true, attribute: 'current-step', type: Number }) + currentStep; + + /** + * Determines whether the side panel should render with an overlay + */ + @property({ attribute: 'include-overlay', type: Boolean, reflect: true }) + includeOverlay = false; + + /** + * Sets the label text which will display above the title text + */ + @property({ reflect: true, attribute: 'label-text' }) + labelText; + + /** + * Sets the icon description for the navigation back icon button + */ + @property({ reflect: true, attribute: 'navigation-back-icon-description' }) + navigationBackIconDescription = 'Back'; + + /** + * `true` if the side-panel should be open. + */ + @property({ type: Boolean, reflect: true }) + open = false; + + /** + * SidePanel placement. + */ + @property({ reflect: true, type: String }) + placement = SIDE_PANEL_PLACEMENT.RIGHT; + + /** + * Prevent closing on click outside of side-panel + */ + @property({ type: Boolean, attribute: 'prevent-close-on-click-outside' }) + preventCloseOnClickOutside = false; + + /** + * The initial location of focus in the side panel + */ + @property({ + reflect: true, + attribute: 'selector-initial-focus', + type: String, + }) + selectorInitialFocus; + + /** + * Selector for page content, used to push content to side except + */ + @property({ reflect: true, attribute: 'selector-page-content' }) + selectorPageContent = ''; + + /** + * SidePanel size. + */ + @property({ reflect: true, type: String }) + size = SIDE_PANEL_SIZE.MEDIUM; + + /** + * Determines if this panel slides in + */ + @property({ attribute: 'slide-in', type: Boolean, reflect: true }) + slideIn = false; + + /** + * Sets the title text + */ + @property({ reflect: false, type: String }) + title; + + async connectObservers() { + await this.updateComplete; + this._hObserveResize = observeResize(this._resizeObserver, this._sidePanel); + } + + disconnectObservers() { + if (this._hObserveResize) { + this._hObserveResize = this._hObserveResize.release(); + } + + if (this._innerContent) { + this._innerContent.removeEventListener('scroll', this._scrollObserver); + } + } + + connectedCallback() { + super.connectedCallback(); + this.disconnectObservers(); + this.connectObservers(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.disconnectObservers(); + } + + render() { + const { + animateTitle, + closeIconDescription, + condensedActions, + currentStep, + includeOverlay, + labelText, + navigationBackIconDescription, + open, + placement, + size, + slideIn, + title, + } = this; + + if (!open && !this._isOpen) { + return html``; + } + + const actionsMultiple = ['', 'single', 'double', 'triple'][ + this._actionsCount + ]; + + const titleTemplate = html` +
0} + ?has-title=${!!title} + ?no-title-animation=${!animateTitle} + ?reduced-motion=${this._reducedMotion.matches} + ?has-action-toolbar=${this._hasActionToolbar}> + + ${currentStep > 0 + ? html` + ${ArrowLeft16({ slot: 'icon' })} + ` + : ''} + + + ${title?.length && labelText?.length + ? html`

${labelText}

` + : ''} + + + ${animateTitle && title?.length && !this._reducedMotion.matches + ? html`` + : ''} + + + ${title?.length + ? html`

+ ${title} +

` + : ''} +
+ + +
+ + + + ${Close20({ slot: 'icon' })} + +
+ + +

+ +

+ +
+ +
+ `; + + return html` + + + ${includeOverlay + ? html`
` + : ''} + `; + } + + checkSetOpen = () => { + const { _sidePanel: sidePanel } = this; + if (sidePanel && this._isOpen) { + // wait until the side panel has transitioned off the screen to remove + sidePanel.addEventListener('transitionend', () => { + this._isOpen = false; + }); + } else { + // allow the html to render before animating in the side panel + window.requestAnimationFrame(() => { + this._isOpen = this.open; + }); + } + }; + + adjustPageContent = () => { + // sets/resets styles based on slideIn property and selectorPageContent; + if (this.selectorPageContent) { + const pageContentEl: HTMLElement | null = document.querySelector( + this.selectorPageContent + ); + + if (pageContentEl) { + const newValues = { + marginInlineStart: '', + marginInlineEnd: '', + inlineSize: '', + transition: this._reducedMotion.matches + ? 'none' + : `all ${moderate02}`, + transitionProperty: 'margin-inline-start, margin-inline-end', + }; + if (this.open) { + newValues.inlineSize = 'auto'; + if (this.placement === 'left') { + newValues.marginInlineStart = `${this._sidePanel.offsetWidth}px`; + } else { + newValues.marginInlineEnd = `${this._sidePanel.offsetWidth}px`; + } + } + + Object.keys(newValues).forEach((key) => { + pageContentEl.style[key] = newValues[key]; + }); + } + } + }; + + firstUpdated() { + this.checkSetOpen(); + this.adjustPageContent(); + this._setMeasuredCustomProperties('first update'); + } + + async updated(changedProperties) { + this.checkSetOpen(); + + if ( + changedProperties.has('slide-in') || + changedProperties.has('open') || + changedProperties.has('include-overlay') + ) { + this.adjustPageContent(); + } + if (changedProperties.has('open')) { + this.disconnectObservers(); + if (this.open) { + this.connectObservers(); + this._setMeasuredCustomProperties('update'); + + this._launcher = this.ownerDocument!.activeElement; + const focusNode = + this.selectorInitialFocus && + this.querySelector(this.selectorInitialFocus); + + await (this.constructor as typeof CDSSidePanel)._delay(); + if (focusNode) { + // For cases where a `carbon-web-components` component (e.g. ``) being `primaryFocusNode`, + // where its first update/render cycle that makes it focusable happens after ``'s first update/render cycle + (focusNode as HTMLElement).focus(); + } else if ( + !tryFocusElems( + this.querySelectorAll( + (this.constructor as typeof CDSSidePanel).selectorTabbable + ), + true + ) + ) { + this.focus(); + } + } else if ( + this._launcher && + typeof (this._launcher as HTMLElement).focus === 'function' + ) { + (this._launcher as HTMLElement).focus(); + this._launcher = null; + } + + // monitor scroll + if (this._innerContent) { + this._innerContent.addEventListener('scroll', this._scrollObserver); + } + } + } + + /** + * @param ms The number of milliseconds. + * @returns A promise that is resolves after the given milliseconds. + */ + private static _delay(ms = 0) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + + /** + * A selector selecting tabbable nodes. + */ + static get selectorTabbable() { + return selectorTabbable; + } + + /** + * The name of the custom event fired before this side-panel is being closed upon a user gesture. + * Cancellation of this event stops the user-initiated action of closing this side-panel. + */ + static get eventBeforeClose() { + return `${prefix}-side-panel-beingclosed`; + } + + /** + * The name of the custom event fired after this side-panel is closed upon a user gesture. + */ + static get eventClose() { + return `${prefix}-side-panel-closed`; + } + + /** + * The name of the custom event fired on clicking the navigate back button + */ + static get eventNavigateBack() { + return `${prefix}-side-panel-header-navigate-back`; + } + + static styles = styles; // `styles` here is a `CSSResult` generated by custom WebPack loader +} + +export default CDSSidePanel; diff --git a/packages/carbon-web-components/src/components/side-panel/story-styles.scss b/packages/carbon-web-components/src/components/side-panel/story-styles.scss new file mode 100644 index 00000000000..1bf12373ef5 --- /dev/null +++ b/packages/carbon-web-components/src/components/side-panel/story-styles.scss @@ -0,0 +1,46 @@ +/* +* Copyright IBM Corp. 2023, 2024 +* +* This source code is licensed under the Apache-2.0 license found in the +* LICENSE file in the root directory of this source tree. +*/ +@use '@carbon/styles/scss/theme' as *; + +$story-prefix: 'side-panel-stories__'; + +.#{$story-prefix}body-content { + display: flex; + flex-direction: column; + padding: 1rem; + gap: 1rem; +} + +.#{$story-prefix}text-inputs { + display: flex; + gap: 1rem; + + > * { + flex-basis: 50%; + } +} + +.#{$story-prefix}story-container { + position: fixed; + display: grid; + block-size: 100vh; + grid-template-rows: 3rem 1fr; + inline-size: 100vw; + inset-block-start: 0; + inset-inline-start: 0; +} + +.#{$story-prefix}story-header { + background: $background-inverse; +} + +.#{$story-prefix}story-content { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/packages/carbon-web-components/src/globals/settings.ts b/packages/carbon-web-components/src/globals/settings.ts index b2c7199f20c..f8da0407465 100644 --- a/packages/carbon-web-components/src/globals/settings.ts +++ b/packages/carbon-web-components/src/globals/settings.ts @@ -52,6 +52,7 @@ const selectorTabbable = ` ${prefix}-tab, ${prefix}-filter-tag, ${prefix}-textarea, + ${prefix}-text-input, ${prefix}-clickable-tile, ${prefix}-expandable-tile, ${prefix}-radio-tile, diff --git a/packages/utilities/src/utilities/settings/settings.js b/packages/utilities/src/utilities/settings/settings.js index c5e42071627..258e9b2146d 100644 --- a/packages/utilities/src/utilities/settings/settings.js +++ b/packages/utilities/src/utilities/settings/settings.js @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2020, 2023 + * Copyright IBM Corp. 2020, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. diff --git a/tasks/get-changelog.js b/tasks/get-changelog.js index 234badf399f..a190d6bd878 100644 --- a/tasks/get-changelog.js +++ b/tasks/get-changelog.js @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Copyright IBM Corp. 2020, 2023 + * Copyright IBM Corp. 2020, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. diff --git a/yarn.lock b/yarn.lock index 840073d7902..87d693151d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3112,6 +3112,19 @@ __metadata: languageName: node linkType: hard +"@carbon/ibm-products-styles@npm:2.20.1": + version: 2.20.1 + resolution: "@carbon/ibm-products-styles@npm:2.20.1" + peerDependencies: + "@carbon/grid": ^11.21.1 + "@carbon/layout": ^11.20.1 + "@carbon/motion": ^11.16.1 + "@carbon/themes": ^11.28.0 + "@carbon/type": ^11.25.1 + checksum: 10/a069cae6b7a4eb23d8b2480ac28bea80660f0893cd810c80d6ad148276d2f9f772c1a965983efcea853bcccdb794039c9c555893b294544b98df218fd14e6fbd + languageName: node + linkType: hard + "@carbon/ibmdotcom-services-store@npm:1.53.0, @carbon/ibmdotcom-services-store@workspace:packages/services-store": version: 0.0.0-use.local resolution: "@carbon/ibmdotcom-services-store@workspace:packages/services-store" @@ -3630,6 +3643,7 @@ __metadata: "@babel/runtime": "npm:^7.16.3" "@babel/template": "npm:~7.12.0" "@babel/traverse": "npm:~7.23.7" + "@carbon/ibm-products-styles": "npm:2.20.1" "@carbon/icon-helpers": "npm:10.45.1" "@carbon/icons": "npm:11.34.1" "@carbon/styles": "npm:1.48.1"