From dbeb15f92564f6502423d57372bd70572e71c953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 14:29:41 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=94=A8=20Restructure=20ToolTip=20to?= =?UTF-8?q?=20have=20a=20default=20slot=20and=20a=20content=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ToolTip.vue | 123 +++++++++++++++++++++++++++---------- 1 file changed, 90 insertions(+), 33 deletions(-) diff --git a/src/components/ToolTip.vue b/src/components/ToolTip.vue index 4f0880f..2396458 100644 --- a/src/components/ToolTip.vue +++ b/src/components/ToolTip.vue @@ -1,46 +1,64 @@ From fcfa2625922f41abb36a753e7ac026184802fff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 14:38:03 +0200 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=94=A8=20Drop=20TooltipPosition.None?= =?UTF-8?q?=20in=20favor=20of=20a=20beak=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ToolTip.vue | 18 ++++++------------ src/definitions.ts | 1 - 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/components/ToolTip.vue b/src/components/ToolTip.vue index 2396458..649d8c5 100644 --- a/src/components/ToolTip.vue +++ b/src/components/ToolTip.vue @@ -7,11 +7,13 @@ import { TooltipPosition } from '@/definitions'; interface Props { position?: TooltipPosition; visible?: boolean; + beak?: boolean; dataTestid?: string; } withDefaults(defineProps(), { position: TooltipPosition.Bottom, visible: undefined, // Explicit undefined default so Vue doesn't auto-cast an absent boolean prop to false. + beak: true, dataTestid: 'tool-tip', }); @@ -24,7 +26,7 @@ const tooltipId = useId(); - + Date: Thu, 24 Sep 2026 14:43:23 +0200 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=94=A8=20Update=20stories=20and=20tes?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ToolTip.stories.ts | 74 +++++++++++++++++++++---------- test/components/ToolTip.test.js | 66 +++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 32 deletions(-) diff --git a/src/components/ToolTip.stories.ts b/src/components/ToolTip.stories.ts index 9e6678b..d15dcc8 100644 --- a/src/components/ToolTip.stories.ts +++ b/src/components/ToolTip.stories.ts @@ -11,10 +11,11 @@ const meta: Meta = { tags: ['autodocs'], argTypes: { position: { control: 'select', options: Object.values(TooltipPosition) }, - default: { control: 'text' }, + beak: { control: 'boolean' }, + content: { control: 'text' }, }, args: { - default: 'Primary', + content: 'Primary', }, }; @@ -22,16 +23,22 @@ export default meta; type Story = StoryObj; export const Standard: Story = { - decorators: [ - (story) => ({ - components: { story }, - template: `
`, - }), - ], + render: (args) => ({ + components: { ToolTip }, + setup() { + return { args }; + }, + template: `
+ + + + +
`, + }), parameters: { docs: { source: { - code: 'Primary', + code: '\n \n \n', }, }, }, @@ -40,18 +47,36 @@ export const Standard: Story = { export const Position: Story = { render: () => ({ components: { ToolTip }, - template: `
-
Pointing upwards
-
Pointing left
-
Pointing downwards
-
Pointing right
-
Pointing nowhere
+ template: `
+ + + + +
`, + }), + parameters: { + docs: { + source: { + code: '\n\n\n', + }, + }, + }, +}; + +export const NoBeak: Story = { + render: () => ({ + components: { ToolTip }, + template: `
+ + + +
`, }), parameters: { docs: { source: { - code: 'Pointing upwards\nPointing left\nPointing downwards\nPointing right\nPointing nowhere', + code: '\n\n\n', }, }, }, @@ -60,20 +85,21 @@ export const Position: Story = { export const Context: Story = { render: () => ({ components: { ToolTip, PrimaryButton }, - template: `
- - Copy the booking link - - - This button copies to clipboard + template: `
+ + + Copy the booking link + + -

Tooltips are currently not assigned to a specific element, but placed and positioned separately. This will be improved in the future.

`, }), parameters: { docs: { source: { - code: '
\n \n Copy the booking link\n \n \n This button copies to clipboard\n \n
', + code: '\n \n Copy the booking link\n \n \n', }, }, }, diff --git a/test/components/ToolTip.test.js b/test/components/ToolTip.test.js index b89ab96..149e593 100644 --- a/test/components/ToolTip.test.js +++ b/test/components/ToolTip.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { h } from 'vue'; import { mount } from '@vue/test-utils'; import ToolTip from '@/components/ToolTip.vue'; import { TooltipPosition } from '@/definitions'; @@ -8,7 +9,6 @@ describe('ToolTip', () => { var wrapper; const positions = [ - TooltipPosition.None, TooltipPosition.Top, TooltipPosition.Bottom, TooltipPosition.Left, @@ -23,11 +23,13 @@ describe('ToolTip', () => { it.each(positions)('%s renders correctly', (position) => { const ourProps = { position: position, - alt: `alt-${position}`, + beak: true, dataTestid: `tooltip-test-${position}`, }; const ourSlots = { - default: 'Hello!', + // scoped default slot: the trigger, wired up with the tooltipId it's given + default: (slotProps) => h('button', { 'aria-describedby': slotProps.tooltipId }, 'Trigger'), + content: 'Hello!', }; wrapper = mount(ToolTip, { @@ -37,19 +39,67 @@ describe('ToolTip', () => { expect(wrapper.props()).toEqual(ourProps); - // verify exists and is displayed with correct text + // verify tooltip bubble exists, is a proper aria tooltip and shows the content slot text const tooltipSelector = `[data-testid=${ourProps['dataTestid']}]`; const toolTip = wrapper.find(tooltipSelector); expect(toolTip.exists()).toBe(true); expect(toolTip.isVisible()).toBe(true); - expect(toolTip.attributes()['aria-label']).toBe(ourProps['alt']); - expect(toolTip.text()).toBe(ourSlots['default']); + expect(toolTip.attributes().role).toBe('tooltip'); + expect(toolTip.attributes().id).toBeTruthy(); + expect(toolTip.text()).toBe('Hello!'); + expect(toolTip.classes()).toContain(ourProps['position']); - // verify position 'beak' + // verify the trigger (default slot) was handed the same id to describe itself with + const trigger = wrapper.find('button'); + expect(trigger.exists()).toBe(true); + expect(trigger.attributes()['aria-describedby']).toBe(toolTip.attributes().id); + + // verify beak exists (its visibility per position is CSS-only, not asserted here) const container = toolTip.find('.tooltip-container'); expect(container.exists()).toBe(true); const beak = container.find('.beak'); expect(beak.exists()).toBe(true); - expect(beak.attributes().class).toBe(`beak ${ourProps['position']}`); + }); + + it.each([ + { visible: true, expectedClass: 'force-visible' }, + { visible: false, expectedClass: 'force-hidden' }, + ])('visible=$visible forces the $expectedClass class', ({ visible, expectedClass }) => { + wrapper = mount(ToolTip, { + propsData: { visible }, + slots: { default: 'Trigger', content: 'Hello!' }, + }); + + const toolTip = wrapper.find('[data-testid=tool-tip]'); + expect(toolTip.classes()).toContain(expectedClass); + }); + + it('visible unset leaves hover/focus in control (no force class)', () => { + wrapper = mount(ToolTip, { + slots: { default: 'Trigger', content: 'Hello!' }, + }); + + const toolTip = wrapper.find('[data-testid=tool-tip]'); + expect(toolTip.classes()).not.toContain('force-visible'); + expect(toolTip.classes()).not.toContain('force-hidden'); + }); + + it('beak defaults to visible', () => { + wrapper = mount(ToolTip, { + slots: { default: 'Trigger', content: 'Hello!' }, + }); + + const toolTip = wrapper.find('[data-testid=tool-tip]'); + expect(toolTip.classes()).not.toContain('no-beak'); + }); + + it('beak=false hides the beak', () => { + wrapper = mount(ToolTip, { + propsData: { beak: false }, + slots: { default: 'Trigger', content: 'Hello!' }, + }); + + const toolTip = wrapper.find('[data-testid=tool-tip]'); + expect(toolTip.classes()).toContain('no-beak'); }); }); From 10be2d0aa338932ec10931a25777dbe584af6341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Thu, 24 Sep 2026 14:44:25 +0200 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=94=A8=20Migrate=20BaseButton=20compo?= =?UTF-8?q?nent=20to=20the=20new=20ToolTip=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BaseButton.vue | 51 +++++++++++++++--------------- test/components/BaseButton.test.js | 4 +-- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/components/BaseButton.vue b/src/components/BaseButton.vue index 2936311..d510945 100644 --- a/src/components/BaseButton.vue +++ b/src/components/BaseButton.vue @@ -29,7 +29,32 @@ withDefaults(defineProps(), { @@ -76,27 +92,10 @@ html { From c861c0bd15fb4f37a82f2d32aef50bd35630cedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 25 Sep 2026 13:08:13 +0200 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=94=A8=20Fix=20visibility=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ToolTip.vue | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/ToolTip.vue b/src/components/ToolTip.vue index ebe616a..ad8282a 100644 --- a/src/components/ToolTip.vue +++ b/src/components/ToolTip.vue @@ -67,6 +67,7 @@ const tooltipId = useId(); 0px 4px 8px 0px rgba(0, 0, 0, 0.14), 0px 0px 2px 0px rgba(0, 0, 0, 0.12); opacity: 0; + visibility: hidden; pointer-events: none; } @@ -143,18 +144,30 @@ const tooltipId = useId(); .tooltip-anchor:focus-within .tooltip, .tooltip.force-visible { opacity: 1; + visibility: visible; pointer-events: auto; z-index: 110; } .tooltip.force-hidden { opacity: 0 !important; + visibility: hidden !important; pointer-events: none !important; } @media (prefers-reduced-motion: no-preference) { .tooltip { - transition: opacity 250ms ease-out; + transition: + opacity 250ms ease-out, + visibility 0s linear 250ms; /* Delay hiding until the fade-out finishes */ + } + + .tooltip-anchor:hover .tooltip, + .tooltip-anchor:focus-within .tooltip, + .tooltip.force-visible { + transition: + opacity 250ms ease-out, + visibility 0s linear 0s; } }