Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(contextual-help): add contextual help pattern #4285

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ executors:
parameters:
current_golden_images_hash:
type: string
default: c5a1a42567a3c179a5e89f57c99e12c1058d682a
default: 8385907444b2bfd2a11c61d3fc67fc0eefdb07eb
wireit_cache_name:
type: string
default: wireit
Expand Down
2 changes: 2 additions & 0 deletions packages/contextual-help/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
stories
test
86 changes: 86 additions & 0 deletions packages/contextual-help/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
## Description

A `sp-contextual-help` shows a user extra information about the state of either an adjacent component or an entire view. It explains a high-level topic about an experience and can point users to more information elsewhere.

### Usage

[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/contextual-help?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/contextual-help)
[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/contextual-help?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/contextual-help)

```
yarn add @spectrum-web-components/contextual-help
```

Import the side effectful registration of `<sp-contextual-help>` via:

```
import '@spectrum-web-components/contextual-help/sp-contextual-help.js';
```

When looking to leverage the `ContextualHelp` base class as a type and/or for extension purposes, do so via:

```
import { ContextualHelp } from '@spectrum-web-components/contextual-help';
```

## Example

```html
<sp-contextual-help>
<h2 slot="heading">Permission required</h2>
Your admin must grant you permission before you can create a segment.
<sp-link
slot="link"
href="https://opensource.adobe.com/spectrum-web-components/"
>
Request permission
</sp-link>
</sp-contextual-help>
```

## Help

Use `variant="help"` for helpful content: more detailed, in-depth guidance about a task, UI element, tool, or keyboard shortcuts. This may include an image, video, or link and should be helpful in tone.

```html
<sp-contextual-help variant="help">
<h2 slot="heading">What is a segment?</h2>
Segments identify who your visitors are, what devices and services they use,
where they navigate from, and much more.
<sp-link
slot="link"
href="https://opensource.adobe.com/spectrum-web-components/"
>
Learn more about segments
</sp-link>
</sp-contextual-help>
```

## Placement

By default a `sp-contextual-help` will render its popover at the `bottom-start` position. This can be customized using the `placement` attribute and supports [all the placement options](http://localhost:8000/components/overlay-trigger/#placement) an `overlay-trigger` component supports.

```html
<sp-contextual-help placement="top-start">
<h2 slot="heading">Permission required</h2>
Your admin must grant you permission before you can create a segment.
<sp-link
slot="link"
href="https://opensource.adobe.com/spectrum-web-components/"
>
Request permission
</sp-link>
</sp-contextual-help>
```

## Events

`sp-contextual-help` does not fire any events of its own. You can listen, however, for the `sp-open` and `sp-closed` events which are fired when the popover opens or closes.

## Accessibility

Given that the trigger is an icon-only `sp-action-button`, it is important to provide an accessible name for it, so that it can be properly announced by screen readers.
By default, the `sp-contextual-help` uses an `aria-label` property with either "Informations" or "Help" as values, depending on the component's `variant`.
You can customize this using the `label` attribute.

When providing headings using the `heading` slot, make sure to provide actual heading elements such as `h1`, `h2`, `h3` ... or use the `role="heading"` attribute.
4 changes: 4 additions & 0 deletions packages/contextual-help/exports.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"./src/*": "./src/*.js",
"./sp-contextual-help.js": "./sp-contextual-help.js"
}
77 changes: 77 additions & 0 deletions packages/contextual-help/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"name": "@spectrum-web-components/contextual-help",
"version": "0.0.1",
"publishConfig": {
"access": "public"
},
"description": "Web component implementation of a Spectrum design ContextualHelp",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/adobe/spectrum-web-components.git",
"directory": "packages/contextual-help"
},
"author": "",
"homepage": "https://adobe.github.io/spectrum-web-components/components/contextual-help",
"bugs": {
"url": "https://github.com/adobe/spectrum-web-components/issues"
},
"main": "src/index.js",
"module": "src/index.js",
"type": "module",
"exports": {
".": {
"development": "./src/index.dev.js",
"default": "./src/index.js"
},
"./package.json": "./package.json",
"./src/ContextualHelp.js": {
"development": "./src/ContextualHelp.dev.js",
"default": "./src/ContextualHelp.js"
},
"./src/contextual-help.css.js": "./src/contextual-help.css.js",
"./src/index.js": {
"development": "./src/index.dev.js",
"default": "./src/index.js"
},
"./sp-contextual-help.js": {
"development": "./sp-contextual-help.dev.js",
"default": "./sp-contextual-help.js"
}
},
"scripts": {
"test": "echo \"Error: run tests from mono-repo root.\" && exit 1"
},
"files": [
"**/*.d.ts",
"**/*.js",
"**/*.js.map",
"custom-elements.json",
"!stories/",
"!test/"
],
"keywords": [
"spectrum css",
"web components",
"lit-element",
"lit-html"
],
"dependencies": {
"@spectrum-web-components/action-button": "^0.42.2",
"@spectrum-web-components/base": "^0.42.2",
"@spectrum-web-components/dialog": "^0.42.2",
"@spectrum-web-components/icons-workflow": "^0.42.2",
"@spectrum-web-components/overlay": "^0.42.2",
"@spectrum-web-components/popover": "^0.42.2"
},
"devDependencies": {
"@spectrum-css/contextualhelp": "^2.1.5"
},
"types": "./src/index.d.ts",
"customElements": "custom-elements.json",
"sideEffects": [
"./sp-*.js",
"./**/*.dev.js",
"./**/*.dev.js"
]
}
20 changes: 20 additions & 0 deletions packages/contextual-help/sp-contextual-help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
Copyright 2024 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
import { ContextualHelp } from './src/ContextualHelp.js';

customElements.define('sp-contextual-help', ContextualHelp);

declare global {
interface HTMLElementTagNameMap {
'sp-contextual-help': ContextualHelp;
}
}
175 changes: 175 additions & 0 deletions packages/contextual-help/src/ContextualHelp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
Copyright 2024 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
import {
CSSResultArray,
html,
render,
SpectrumElement,
TemplateResult,
} from '@spectrum-web-components/base';
import '@spectrum-web-components/action-button/sp-action-button.js';
import '@spectrum-web-components/overlay/sp-overlay.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-help-outline.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-info-outline.js';
import { property } from '@spectrum-web-components/base/src/decorators.js';
import { ifDefined } from '@spectrum-web-components/base/src/directives.js';
import type { Placement } from '@spectrum-web-components/overlay/src/overlay-types.js';
import {
removeSlottableRequest,
SlottableRequestEvent,
} from '@spectrum-web-components/overlay/src/slottable-request-event.js';
import {
IS_MOBILE,
MatchMediaController,
} from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js';
import styles from './contextual-help.css.js';

/**
* Spectrum Contextual help provides additional information about
* the state of either an adjacent component or an entire view.
* @element sp-contextual-help
*
* @slot heading - content to display as the heading of the popover
* @slot Text content to display in the popover
* @slot link - link to additional informations
*/
export class ContextualHelp extends SpectrumElement {
protected isMobile = new MatchMediaController(this, IS_MOBILE);

public static override get styles(): CSSResultArray {
return [styles];
}

/**
* Provides an accessible name for the action button trigger.
* @param {String} label
*/
@property()
public label?: string;

/**
* The `variant` property applies specific styling on the action button trigger.
* @param {String} variant
*/
@property()
public variant: 'info' | 'help' = 'info';

/**
* @type {"top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end"}
* @attr
*/
@property({ reflect: true })
public placement?: Placement = 'bottom-start';

/**
* The `offset` property accepts either a single number, to
* define the offset of the Popover along the main axis from
* the action button, or 2-tuple, to define the offset along the
* main axis and the cross axis.
*/
@property({ type: Number })
public offset: number | [number, number] = 0;

@property({ type: Boolean })
open = false;

private get buttonAriaLabel(): string {
if (this.label) {
return this.label;
} else {
if (this.variant === 'help') {
return 'Help';
}
return 'Informations';
}
}

private renderOverlayContent(): TemplateResult {
if (this.isMobile.matches) {
import('@spectrum-web-components/dialog/sp-dialog-base.js');
import('@spectrum-web-components/dialog/sp-dialog.js');

return html`
<sp-dialog-base underlay>
<sp-dialog dismissable size="s">
<slot name="heading" slot="heading"></slot>
<slot></slot>
<slot name="link"></slot>
</sp-dialog>
</sp-dialog-base>
`;
} else {
import('@spectrum-web-components/popover/sp-popover.js');

return html`
<sp-popover class="popover">
<section>
<slot name="heading"></slot>
<slot></slot>
<slot name="link"></slot>
</section>
</sp-popover>
`;
}
}

private handleSlottableRequest(event: SlottableRequestEvent): void {
event.stopPropagation();

if (event.data === removeSlottableRequest) {
this.open = false;
render(undefined, event.target as HTMLElement);
return;
}

this.open = true;
const template = this.renderOverlayContent();
render(template, event.target as HTMLElement);
}

protected override render(): TemplateResult {
const actualPlacement = this.isMobile.matches
? undefined
: this.placement;

return html`
<sp-action-button
quiet
size="s"
id="trigger"
aria-label=${this.buttonAriaLabel}
.active=${this.open}
>
${this.variant === 'help'
? html`
<sp-icon-help-outline
slot="icon"
></sp-icon-help-outline>
`
: html`
<sp-icon-info-outline
slot="icon"
></sp-icon-info-outline>
`}
</sp-action-button>
<sp-overlay
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may also want to look at the trigger directive here, as it lists as slightly more performant that leveraging the element.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not aware of this directive. I think it is really cool that is handles the slottable-request event for you.
A thing I did not found straight-forward is the open/close events. Is there a better way to know when it opens/closes events besides adding event listeners on the ContextualHelp class?
I'd need to know of open/close states for the button's active state. Would you consider a good addition to add active attribute on the trigger as a built-in feature of the trigger directive?

Copy link
Contributor

@Westbrook Westbrook Apr 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not even "brand new" quite yet, as we're working through its specifics internally and with some select early clients before we mark it as "for consumption". Some approach to this seems like an important part of this API, but I'm not sure direct manual handling of specific attributes inside of the directive is the right thing to do here.

While not pretty, that best I can think of with the established API is to use specific handing of slottable-request to toggle open which is rendered to active. In API extensions, I'd love to chat more on the possibilities around:

  • adding handleOpen/handleClose callbacks to the options bag
  • advanced used of the trigger directive as a TriggerController that held the open state for you to be able to render with (feels either really smart of trying too hard)
  • other (better) things...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a consumer, the first thing I looked for was a handleOpen and handleClose callback, as slottable-request event is part of the internal works of this directive and I don't know if as a consumer I should be aware of that.

sp-open/sp-close events are fine, but are actually a bit delayed, because of the CSS transitions that have dispatched I guess. So the question with handleOpen / handleClose would be if the timing of calling these callbacks should be on the handleSlottableRequest method, or a bit late, on sp-open and sp-closed.

In my case as you mentioned sp-open/sp-close are a bit late :(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sp-open and sp-close are not "correct for this situation.

If we went with callbacks, they would likely happen at slottable-request time, because that represents the outside of the open/close transition, rather than only the end of each transition. But, it could be argued that this belongs at the beginning of each transition, which is when the state actually changes. 🤔

Relying on the state feels nicer than relying on a knowable part of the lifecycle, which is why I like the TriggerController, but there is much to think about here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that best I can think of with the established API is to use specific handing of slottable-request to toggle open which is rendered to active.

Regarding this, I don't think this works, as the event is attached on the sp-overlay, which I don't have access to.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. So we can ship once and edit later leveraging <sp-overlay> directly, or we can hold this for an expansion of the directives capabilities. Do you have a preference on that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending on when exactly would the directive be expanded. If it's more than 1-2 releases, then I'd ship it as it is and come back when this behaviour is available.

trigger="trigger@click"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving to open likely means moving to .triggerElement=... instead and ?open=${this.open} which them means you might want to leverage DependencyManagerController as seen in Picker.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Rocss Updates on this? Do you think using DependencyManagerController here would benefit the component?

Copy link
Contributor Author

@Rocss Rocss May 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tarun I don't understand why moving to open would mean moving to .triggerElement=... instead.

I tried using the DependencyManagerController but I found out it does not play well with the slottable-request event:
You need to open the sp-overlay in order to register the dependencies, but you can not open it unless the dependencies are registered. (?open=${this.open &&this.dependencyManager.loaded})

placement=${ifDefined(actualPlacement)}
type=${this.isMobile.matches ? 'modal' : 'auto'}
receives-focus="true"
.offset=${this.offset}
@slottable-request=${this.handleSlottableRequest}
?open=${this.open}
></sp-overlay>
`;
}
}