Skip to content

Commit a7ca89d

Browse files
committed
Merge branch 'main' into feature/2178-time-field
2 parents 71b11d6 + d3b9d45 commit a7ca89d

File tree

8 files changed

+250
-31
lines changed

8 files changed

+250
-31
lines changed

.changeset/afraid-tools-lay.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sl-design-system/tooltip': minor
3+
---
4+
5+
Adds support for passing a config object to the tooltip directive (same as `Tooltip.lazy`).
6+
The directive now accepts either a string (content) or a `TooltipConfig` with configurable properties like: `position`, `maxWidth` and `ariaRelation`.

.changeset/upset-ghosts-call.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sl-design-system/eslint-plugin-slds': minor
3+
---
4+
5+
Improved 'button-has-label' rule: it now also accepts the tooltip directive when `ariaRelation: 'label'` is set.

packages/components/tooltip/src/tooltip-directive.spec.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { type ScopedElementsMap, ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
22
import { expect, fixture } from '@open-wc/testing';
33
import { Button } from '@sl-design-system/button';
4+
import { type PopoverPosition } from '@sl-design-system/shared';
45
import { LitElement, type TemplateResult, html } from 'lit';
56
import { spy, stub } from 'sinon';
67
import { tooltip } from './tooltip-directive.js';
78
import { Tooltip } from './tooltip.js';
89

910
describe('tooltip()', () => {
1011
it('should create a lazy tooltip on the host element', async () => {
11-
spy(Tooltip, 'lazy');
12+
const lazySpy = spy(Tooltip, 'lazy');
1213

1314
const el = await fixture(html`<div ${tooltip('content')}>Host</div>`);
1415

1516
expect(Tooltip.lazy).to.have.been.calledOnce;
1617
expect(Tooltip.lazy).to.have.been.calledWith(el);
18+
19+
lazySpy.restore();
1720
});
1821

1922
it('should log a warning if the custom element is not defined on the document', async () => {
@@ -98,4 +101,62 @@ describe('tooltip()', () => {
98101
// Cleanup
99102
el.remove();
100103
});
104+
105+
it('should pass tooltip options via config to the tooltip', async () => {
106+
try {
107+
if (!customElements.get('sl-tooltip')) {
108+
customElements.define('sl-tooltip', Tooltip);
109+
}
110+
} catch {
111+
// empty
112+
}
113+
114+
const lazySpy = spy(Tooltip, 'lazy');
115+
116+
const el: HTMLElement = await fixture(
117+
html`<div ${tooltip('content', { ariaRelation: 'label' })} tabindex="0">Host</div>`
118+
);
119+
120+
// Trigger the lazy tooltip creation
121+
el.focus();
122+
123+
expect(lazySpy).to.have.been.calledOnce;
124+
expect(lazySpy.getCall(0).args[2]).to.deep.equal({ ariaRelation: 'label' });
125+
126+
const tooltipEl = el.nextElementSibling as Tooltip | null;
127+
128+
expect(tooltipEl).to.exist;
129+
expect(el).to.have.attribute('aria-labelledby', tooltipEl?.id);
130+
131+
lazySpy.restore();
132+
});
133+
134+
it('should apply tooltip properties (position, maxWidth) from config to the tooltip', async () => {
135+
try {
136+
if (!customElements.get('sl-tooltip')) {
137+
customElements.define('sl-tooltip', Tooltip);
138+
}
139+
} catch {
140+
// empty
141+
}
142+
143+
const el: HTMLElement = await fixture(html`
144+
<sl-button ${tooltip('Tooltip example', { position: 'bottom' as PopoverPosition, maxWidth: 150 })} tabindex="0"
145+
>Button</sl-button
146+
>
147+
`);
148+
149+
// Trigger the lazy tooltip creation
150+
el.focus();
151+
152+
const tooltipEl = el.nextElementSibling as Tooltip | null;
153+
154+
expect(tooltipEl).to.exist;
155+
expect(tooltipEl?.position).to.equal('bottom');
156+
expect(tooltipEl?.maxWidth).to.equal(150);
157+
expect(tooltipEl).to.have.text('Tooltip example');
158+
159+
// Cleanup
160+
el.remove();
161+
});
101162
});

packages/components/tooltip/src/tooltip-directive.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
import { render } from 'lit';
22
import { AsyncDirective } from 'lit/async-directive.js';
3-
import { type DirectiveParameters, type ElementPart, directive } from 'lit/directive.js';
4-
import { Tooltip } from './tooltip.js';
3+
import { type ElementPart, directive } from 'lit/directive.js';
4+
import { Tooltip, TooltipOptions } from './tooltip.js';
55

6+
/** Configuration options for the tooltip directive. */
7+
export type TooltipDirectiveConfig = Partial<TooltipOptions> & TooltipProperties;
8+
9+
/** Tooltip public properties that can be set. */
10+
type TooltipProperties = {
11+
position?: Tooltip['position'];
12+
maxWidth?: number;
13+
};
14+
15+
type TooltipDirectiveParams = [content: unknown, config?: TooltipDirectiveConfig];
16+
17+
/** Provides a Lit directive tooltip that attaches a lazily created Tooltip instance to a host element. */
618
export class TooltipDirective extends AsyncDirective {
19+
config: TooltipDirectiveConfig = {};
720
content?: unknown;
821
part?: ElementPart;
922
tooltip?: Tooltip | (() => void);
@@ -22,27 +35,38 @@ export class TooltipDirective extends AsyncDirective {
2235
this.#setup();
2336
}
2437

25-
render(_content: unknown): void {}
38+
render(_content: unknown, _config?: TooltipDirectiveConfig): void {}
2639

2740
renderContent(): void {
2841
render(this.content, this.tooltip as Tooltip, this.part!.options);
2942
}
3043

31-
override update(part: ElementPart, [content]: DirectiveParameters<this>): void {
44+
override update(part: ElementPart, [content, config]: TooltipDirectiveParams): void {
3245
this.content = content;
46+
47+
if (config) {
48+
this.config = { ...this.config, ...config };
49+
}
50+
3351
this.part = part;
3452

3553
this.#setup();
3654
}
3755

3856
#setup(): void {
3957
if (this.part!.element)
40-
this.tooltip ||= Tooltip.lazy(this.part!.element, tooltip => {
41-
if (this.isConnected) {
42-
this.tooltip = tooltip;
43-
this.renderContent();
44-
}
45-
});
58+
this.tooltip ||= Tooltip.lazy(
59+
this.part!.element,
60+
tooltip => {
61+
if (this.isConnected) {
62+
this.tooltip = tooltip;
63+
tooltip.position = this.config.position || 'top';
64+
tooltip.maxWidth = this.config.maxWidth;
65+
this.renderContent();
66+
}
67+
},
68+
{ ariaRelation: this.config.ariaRelation }
69+
);
4670
}
4771
}
4872

packages/components/tooltip/src/tooltip.stories.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,35 @@ export const Directive: Story = {
9696
}
9797
};
9898

99+
export const DirectiveWithOptions: Story = {
100+
render: () => {
101+
return html`
102+
<style>
103+
.container {
104+
display: grid;
105+
height: calc(20rem);
106+
place-items: center;
107+
}
108+
</style>
109+
<p>
110+
This story demonstrates hot to use the tooltip directive with some inline options (custom 'ariaRelation', custom
111+
'position' and 'maxWidth') on a <code>sl-button</code>. The example shows how to add a tooltip directly without
112+
a separate <code>sl-tooltip</code> element.
113+
</p>
114+
115+
<div class="container">
116+
<sl-button
117+
variant="primary"
118+
fill="solid"
119+
${tooltip('My tooltip example', { ariaRelation: 'label', position: 'bottom-start', maxWidth: 100 })}
120+
>
121+
<sl-icon name="face-smile" size="lg"></sl-icon>
122+
</sl-button>
123+
</div>
124+
`;
125+
}
126+
};
127+
99128
export const Disabled: Story = {
100129
args: {
101130
example: ({ alignSelf, justifySelf, message }) => html`

tools/eslint-plugin-slds/lib/rules/button-has-label.js

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,35 +15,52 @@ export const buttonHasLabel = {
1515
fixable: null,
1616
schema: [],
1717
messages: {
18-
missingText: 'sl-button elements must have text content or aria-label for accessibility'
18+
missingText: 'sl-button elements must have text content or aria-label for accessibility',
19+
mustBeAriaRelationLabel: 'for the tooltip directive ariaRelation must be \'label\''
1920
}
2021
},
2122
create(context) {
2223
return {
2324
TaggedTemplateExpression(node) {
24-
if (isHtmlTaggedTemplate(node, context)) {
25-
const analyzer = TemplateAnalyzer.create(node);
25+
if (!isHtmlTaggedTemplate(node, context)) {
26+
return;
27+
}
28+
29+
// Tooltip with ariaRelation: 'label' variant
30+
const templateSource = context.sourceCode.getText(node),
31+
hasTooltip = /tooltip\s*\(/.test(templateSource),
32+
hasTooltipWithAriaRelationLabel = /tooltip\s*\([^)]*ariaRelation\s*:\s*['"]label['"]/.test(templateSource);
2633

27-
analyzer.traverse({
28-
enterElement(element) {
29-
if (element.name === 'sl-button') {
30-
if (hasTextContent(element) || hasAccessibleName(element)) {
31-
return;
32-
}
34+
const analyzer = TemplateAnalyzer.create(node);
3335

34-
const loc =
35-
analyzer.resolveLocation(
36-
element.sourceCodeLocation.startTag,
37-
context.sourceCode,
38-
) ?? node.loc;
36+
analyzer.traverse({
37+
enterElement(element) {
38+
if (element.name !== 'sl-button') {
39+
return;
40+
}
3941

40-
if (loc) {
41-
context.report({ loc, messageId: 'missingText' });
42-
}
42+
if (
43+
hasTextContent(element) ||
44+
hasAccessibleName(element) ||
45+
hasTooltipWithAriaRelationLabel
46+
) {
47+
return;
48+
}
49+
50+
const loc = analyzer.resolveLocation(
51+
element.sourceCodeLocation.startTag,
52+
context.sourceCode
53+
) || node.loc;
54+
55+
if (loc) {
56+
if (hasTooltip && !hasTooltipWithAriaRelationLabel) {
57+
context.report({ loc, messageId: 'mustBeAriaRelationLabel' })
58+
} else if (!hasAccessibleName(element)) {
59+
context.report({ loc, messageId: 'missingText' });
4360
}
4461
}
45-
});
46-
}
62+
}
63+
});
4764
}
4865
};
4966
}

tools/eslint-plugin-slds/tests/lib/rules/button-has-label.test.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ ruleTester.run('button-has-label', buttonHasLabel, {
2121
{ code: "html`<sl-button><slot></slot></sl-button>`;" },
2222
{ code: "html`<sl-button><sl-foo></sl-foo></sl-button>`;" },
2323
{ code: "const template = `<sl-button></sl-button>`;" },
24-
{ code: "html`<div><sl-button>First</sl-button><sl-button>Second</sl-button></div>`;" }
24+
{ code: "html`<div><sl-button>First</sl-button><sl-button>Second</sl-button></div>`;" },
25+
{ code: "html`<sl-button ${tooltip('Toolip example', { ariaRelation: 'label' })}><sl-icon name='face-smile'></sl-icon></sl-button>`;" },
26+
{ code: "html`<sl-button ${tooltip('Tiooltip example', { position: 'bottom-start', ariaRelation: 'label' })}><sl-icon name='face-smile'></sl-icon></sl-button>`;" },
27+
{ code: "html`<sl-button ${tooltip('My tooltip example', { ariaRelation: 'label', position: 'bottom-start', maxWidth: 100 })}><sl-icon name='face-smile' size='lg'></sl-icon></sl-button>`;" },
28+
{ code: "html`<sl-button variant=\"primary\" fill=\"solid\" ${tooltip('My tooltip example', { ariaRelation: 'label', position: 'bottom-start', maxWidth: 100 })}>\n`;" }
2529
],
2630
invalid: [
2731
{
@@ -58,6 +62,14 @@ ruleTester.run('button-has-label', buttonHasLabel, {
5862
{
5963
code: "html`<sl-button variant=\"primary\" class=\"my-button\"></sl-button>`;",
6064
errors: [{ messageId: 'missingText' }]
65+
},
66+
{
67+
code: "html`<sl-button ${tooltip('Tip', { position: 'bottom-start' })}><sl-icon name='face-smile'></sl-icon></sl-button>`;",
68+
errors: [{ messageId: 'mustBeAriaRelationLabel' }]
69+
},
70+
{
71+
code: "html`<sl-button ${tooltip('Tip', { ariaRelation: 'sth else but not label', position: 'bottom-start' })}><sl-icon name='face-smile'></sl-icon></sl-button>`;",
72+
errors: [{ messageId: 'mustBeAriaRelationLabel' }]
6173
}
6274
]
6375
});

website/src/categories/components/tooltip/code.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,71 @@ The complete usage example might appear as follows:
6666

6767
</div>
6868

69+
### Add config with the directive
70+
71+
You can also pass a second argument: a **config** object. Use this to control how the tooltip appears.
72+
73+
<div class="ds-code">
74+
75+
```js
76+
// Basic string content + position + max width
77+
html`<sl-button ${tooltip('More info', { position: 'right', maxWidth: 240 })}>Hover me</sl-button>`;
78+
79+
// Use ariaRelation: 'label' when the tooltip should be used as the accessible label (e.g. icon only buttons)
80+
html`<sl-button ${tooltip('Settings', { ariaRelation: 'label' })}><sl-icon name="smile"></sl-icon></sl-button>`;
81+
```
82+
83+
</div>
84+
85+
### Available config options
86+
87+
- **position**: Where the tooltip shows relative to the anchor. One of: `top`, `right`, `bottom`, `left`, `top-start`, `top-end`, `right-start`, `right-end`, `bottom-start`, `bottom-end`, `left-start`, `left-end`. Default: `top`.
88+
- **maxWidth**: A `number` (pixels). The maximum width of the tooltip.
89+
- **ariaRelation**: How the tooltip is linked for screen readers. A `description` (default) uses `aria-describedby`, `label` uses `aria-labelledby` and should be used when the tooltip text is the actual label of the anchor element (like an icon-only button).
90+
91+
If you omit a `config` it just uses its default behaviour. Config options are optional.
92+
93+
</section>
94+
95+
<section>
96+
97+
## Tooltip.lazy helper
98+
99+
`Tooltip.lazy` is a small helper that creates a tooltip only when the user first hovers or focuses the target element.
100+
This avoids unnecessary processing if the user never interacts with it.
101+
102+
Basic shape:
103+
104+
```ts
105+
import { Tooltip } from '@sl-design-system/tooltip';
106+
107+
const cleanup = Tooltip.lazy(targetElement, tooltip => {
108+
// Runs once when the tooltip is actually created
109+
tooltip.textContent = 'Hello there';
110+
});
111+
```
112+
113+
With options:
114+
115+
```ts
116+
Tooltip.lazy(button, tooltip => {
117+
tooltip.textContent = 'Settings';
118+
tooltip.position = 'bottom-start';
119+
},
120+
{
121+
ariaRelation: 'label',
122+
context: shadowRoot,
123+
parentNode: someContainer,
124+
}
125+
);
126+
```
127+
128+
### Available options
129+
130+
- **ariaRelation**: How the tooltip is linked for screen readers. A `description` (default) uses `aria-describedby`, `label` uses `aria-labelledby` and should be used when the tooltip text is the actual label of the anchor element (like an icon-only button).
131+
- **context**: A `Document` or `ShadowRoot` to create the `<sl-tooltip>` element in. If not provided, the tooltip will be created on the target element if it has a `shadowRoot`, or the root node of the target element.
132+
- **parentNode**: A `Node` where the tooltip element should be inserted. This can be useful when you don't want the tooltip to be added next to the anchor element. If not provided, it will be added next to the anchor element.
133+
69134
</section>
70135

71136
{% include "../component-table.njk" %}

0 commit comments

Comments
 (0)