diff --git a/superset-frontend/src/components/Accessibility/SkipLink.stories.tsx b/superset-frontend/src/components/Accessibility/SkipLink.stories.tsx new file mode 100644 index 000000000000..89472eacb149 --- /dev/null +++ b/superset-frontend/src/components/Accessibility/SkipLink.stories.tsx @@ -0,0 +1,240 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import SkipLink from './SkipLink'; + +export default { + title: 'Components/Accessibility/SkipLink', + component: SkipLink, + parameters: { + docs: { + description: { + component: + 'WCAG 2.4.1 Bypass Blocks - Skip link for keyboard navigation. ' + + 'Allows keyboard users to skip repetitive navigation and jump directly to main content. ' + + 'The link is visually hidden but becomes visible when focused via Tab key.', + }, + }, + }, +}; + +interface SkipLinkArgs { + targetId: string; + children: string; +} + +// Interactive story with controls +export const InteractiveSkipLink = (args: SkipLinkArgs) => ( +
+ +

+ Press Tab to see the skip link appear at the top of the viewport. +

+
+

Target Content

+

This is the target element that will receive focus when the skip link is activated.

+
+
+); + +InteractiveSkipLink.args = { + targetId: 'main-content', + children: 'Skip to main content', +}; + +InteractiveSkipLink.argTypes = { + targetId: { + control: 'text', + description: 'ID of the target element to focus when activated', + table: { + defaultValue: { summary: 'main-content' }, + }, + }, + children: { + control: 'text', + description: 'The text displayed in the skip link', + table: { + defaultValue: { summary: 'Skip to main content' }, + }, + }, +}; + +// Full page demo showing realistic usage +export const FullPageDemo = () => ( +
+ + +
+ +
+ +
+

Main Content Area

+

+ Press Tab to see the skip link appear. Click it or press{' '} + Enter to jump directly here, bypassing the navigation links above. +

+

+ This is essential for keyboard users who would otherwise need to tab through all + navigation items on every page load. +

+
+
+); + +FullPageDemo.parameters = { + docs: { + description: { + story: + 'Full page demonstration showing the skip link in a realistic layout. ' + + 'Press Tab to reveal the skip link, then activate it to jump to main content.', + }, + }, +}; + +// Custom text demo +export const CustomText = () => ( +
+ Direkt zum Inhalt springen +

+ Skip links can be internationalized with custom text. +

+
+

Inhalt

+

Der Hauptinhalt der Seite.

+
+
+); + +CustomText.parameters = { + docs: { + description: { + story: 'Skip link with custom internationalized text (German example).', + }, + }, +}; + +// Multiple skip links +export const MultipleSkipLinks = () => ( +
+
+ Skip to navigation +
+
+ Skip to main content +
+
+ Skip to footer +
+ +

+ Press Tab multiple times to cycle through all skip links. +

+ + + +
+

Main Content

+

The primary content of the page.

+
+ + +
+); + +MultipleSkipLinks.parameters = { + docs: { + description: { + story: + 'Multiple skip links for complex page layouts with distinct sections. ' + + 'Users can choose which section to jump to.', + }, + }, +}; + +// Accessibility testing story +export const AccessibilityTest = () => ( +
+ + Skip to test content + + +
+

Accessibility Checklist

+ +
+ +
+

Target Content

+

This element should receive focus when the skip link is activated.

+
+
+); + +AccessibilityTest.parameters = { + docs: { + description: { + story: + 'Test story for verifying accessibility requirements. ' + + 'Use browser dev tools and screen readers to verify compliance.', + }, + }, +}; diff --git a/superset-frontend/src/components/Accessibility/SkipLink.test.tsx b/superset-frontend/src/components/Accessibility/SkipLink.test.tsx new file mode 100644 index 000000000000..e88420e9f193 --- /dev/null +++ b/superset-frontend/src/components/Accessibility/SkipLink.test.tsx @@ -0,0 +1,345 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import SkipLink from './SkipLink'; + +describe('SkipLink', () => { + beforeEach(() => { + // Clean up any target elements from previous tests + const existingTarget = document.getElementById('main-content'); + if (existingTarget) { + existingTarget.remove(); + } + }); + + describe('Rendering', () => { + test('renders with default "Skip to main content" text', () => { + render(); + expect(screen.getByText('Skip to main content')).toBeInTheDocument(); + }); + + test('renders with custom children text', () => { + render(Skip to navigation); + expect(screen.getByText('Skip to navigation')).toBeInTheDocument(); + }); + + test('renders as anchor element with correct href', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '#custom-target'); + }); + + test('applies styled-component class', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveClass('a11y-skip-link'); + }); + }); + + describe('Visual States', () => { + test('is positioned off-screen by default (top: -100px)', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveStyleRule('top', '-100px'); + }); + + test('has correct z-index for overlay (10000)', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveStyleRule('z-index', '10000'); + }); + + test('has absolute positioning', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveStyleRule('position', 'absolute'); + }); + }); + + describe('Focus Behavior', () => { + test('becomes visible when focused (top: 0)', async () => { + render(); + const link = screen.getByRole('link'); + link.focus(); + expect(link).toHaveStyleRule('top', '0 !important', { + modifier: ':focus', + }); + }); + + test('receives focus on Tab key press', async () => { + const user = userEvent.setup(); + render(); + await user.tab(); + const link = screen.getByRole('link'); + expect(link).toHaveFocus(); + }); + + test('shows focus outline styling', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveStyleRule('outline', expect.stringContaining('3px solid'), { + modifier: ':focus', + }); + }); + + test('shows focus-visible outline styling', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveStyleRule('top', '0 !important', { + modifier: ':focus-visible', + }); + }); + }); + + describe('Keyboard Navigation', () => { + test('activates on Enter key press', async () => { + const user = userEvent.setup(); + const targetElement = document.createElement('main'); + targetElement.id = 'main-content'; + document.body.appendChild(targetElement); + + render(); + await user.tab(); + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(targetElement).toHaveFocus(); + }); + + targetElement.remove(); + }); + + test('activates on click', async () => { + const user = userEvent.setup(); + const targetElement = document.createElement('main'); + targetElement.id = 'main-content'; + document.body.appendChild(targetElement); + + render(); + await user.click(screen.getByRole('link')); + + await waitFor(() => { + expect(targetElement).toHaveFocus(); + }); + + targetElement.remove(); + }); + }); + + describe('Click Handler', () => { + test('prevents default anchor behavior', async () => { + const user = userEvent.setup(); + const targetElement = document.createElement('main'); + targetElement.id = 'main-content'; + document.body.appendChild(targetElement); + + render(); + const link = screen.getByRole('link'); + + const clickEvent = new MouseEvent('click', { bubbles: true }); + const preventDefaultSpy = jest.spyOn(clickEvent, 'preventDefault'); + + link.dispatchEvent(clickEvent); + expect(preventDefaultSpy).toHaveBeenCalled(); + + targetElement.remove(); + }); + + test('finds target element by ID', async () => { + const user = userEvent.setup(); + const targetElement = document.createElement('main'); + targetElement.id = 'custom-target'; + document.body.appendChild(targetElement); + + render(); + await user.click(screen.getByRole('link')); + + await waitFor(() => { + expect(targetElement).toHaveFocus(); + }); + + targetElement.remove(); + }); + + test('focuses target element on click', async () => { + const user = userEvent.setup(); + const targetElement = document.createElement('div'); + targetElement.id = 'main-content'; + document.body.appendChild(targetElement); + + render(); + await user.click(screen.getByRole('link')); + + await waitFor(() => { + expect(targetElement).toHaveFocus(); + }); + + targetElement.remove(); + }); + + test('adds tabindex="-1" to non-focusable targets', async () => { + const user = userEvent.setup(); + const targetElement = document.createElement('div'); + targetElement.id = 'main-content'; + document.body.appendChild(targetElement); + + expect(targetElement).not.toHaveAttribute('tabindex'); + + render(); + await user.click(screen.getByRole('link')); + + await waitFor(() => { + expect(targetElement).toHaveAttribute('tabindex', '-1'); + }); + + targetElement.remove(); + }); + + test('does not add tabindex if already present', async () => { + const user = userEvent.setup(); + const targetElement = document.createElement('div'); + targetElement.id = 'main-content'; + targetElement.setAttribute('tabindex', '0'); + document.body.appendChild(targetElement); + + render(); + await user.click(screen.getByRole('link')); + + await waitFor(() => { + expect(targetElement).toHaveAttribute('tabindex', '0'); + }); + + targetElement.remove(); + }); + + test('handles missing target element gracefully', async () => { + const user = userEvent.setup(); + const originalHash = window.location.hash; + + render(); + + // Should not throw an error + await expect(user.click(screen.getByRole('link'))).resolves.not.toThrow(); + + // Cleanup + window.location.hash = originalHash; + }); + + test('falls back to hash navigation when target missing', async () => { + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('link')); + + expect(window.location.hash).toBe('#non-existent'); + + // Cleanup + window.location.hash = ''; + }); + }); + + describe('Props', () => { + test('uses default targetId "main-content"', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '#main-content'); + }); + + test('accepts custom targetId prop', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '#sidebar'); + }); + + test('passes through additional props', () => { + render(); + expect(screen.getByTestId('custom-skip-link')).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + test('has accessible name from children', () => { + render(Skip navigation); + const link = screen.getByRole('link', { name: 'Skip navigation' }); + expect(link).toBeInTheDocument(); + }); + + test('is discoverable by screen readers', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toBeInTheDocument(); + expect(link).not.toHaveAttribute('aria-hidden'); + }); + + test('href matches targetId for fallback', () => { + render(); + const link = screen.getByRole('link'); + expect(link.getAttribute('href')).toBe('#content-area'); + }); + }); + + describe('Edge Cases', () => { + test('handles empty targetId', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '#'); + }); + + test('handles special characters in targetId', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '#main_content-area'); + }); + + test('handles multiple SkipLinks on page', () => { + render( + <> + Skip to navigation + Skip to main content + Skip to footer + , + ); + + expect(screen.getByText('Skip to navigation')).toBeInTheDocument(); + expect(screen.getByText('Skip to main content')).toBeInTheDocument(); + expect(screen.getByText('Skip to footer')).toBeInTheDocument(); + }); + + test('works with dynamically added target elements', async () => { + const user = userEvent.setup(); + + render(); + + // Add target after component renders + const targetElement = document.createElement('div'); + targetElement.id = 'dynamic-target'; + document.body.appendChild(targetElement); + + await user.click(screen.getByRole('link')); + + await waitFor(() => { + expect(targetElement).toHaveFocus(); + }); + + targetElement.remove(); + }); + }); +}); diff --git a/superset-frontend/src/components/Accessibility/SkipLink.tsx b/superset-frontend/src/components/Accessibility/SkipLink.tsx new file mode 100644 index 000000000000..9a1f84a3bec8 --- /dev/null +++ b/superset-frontend/src/components/Accessibility/SkipLink.tsx @@ -0,0 +1,91 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { styled } from '@superset-ui/core'; + +/** + * SkipLink - WCAG 2.4.1 Bypass Blocks + * Allows keyboard users to skip navigation and jump directly to main content. + * The link is visually hidden but becomes visible when focused. + */ + +const StyledSkipLink = styled.a` + position: absolute; + top: -100px; + left: 0; + background: ${({ theme }) => theme.colors.primary.dark1}; + color: ${({ theme }) => theme.colors.grayscale.light5}; + padding: ${({ theme }) => theme.gridUnit * 3}px ${({ theme }) => theme.gridUnit * 4}px; + z-index: 10000; + text-decoration: none; + font-weight: ${({ theme }) => theme.typography.weights.bold}; + font-size: ${({ theme }) => theme.typography.sizes.m}px; + border-radius: 0 0 ${({ theme }) => theme.borderRadius}px ${({ theme }) => theme.borderRadius}px; + transition: top 0.2s ease-in-out; + + &:focus, + &:focus-visible { + top: 0 !important; + outline: 3px solid ${({ theme }) => theme.colors.primary.light1}; + outline-offset: 2px; + } + + &:hover { + background: ${({ theme }) => theme.colors.primary.base}; + } +`; + +interface SkipLinkProps { + targetId?: string; + children?: React.ReactNode; +} + +const SkipLink: React.FC = ({ + targetId = 'main-content', + children = 'Skip to main content', +}) => { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + const el = document.getElementById(targetId); + if (el) { + // Make sure the element is focusable and focus it + // Note: We intentionally keep the tabindex to ensure the element remains focusable + // for subsequent keyboard navigation (fixes skip link accessibility) + if (!el.hasAttribute('tabindex')) { + el.setAttribute('tabindex', '-1'); + } + el.focus(); + } else { + // Fallback to fragment navigation if target not present + window.location.hash = `#${targetId}`; + } + }; + + return ( + + {children} + + ); +}; + +export default SkipLink; diff --git a/superset-frontend/src/components/Accessibility/StatusAnnouncer.stories.tsx b/superset-frontend/src/components/Accessibility/StatusAnnouncer.stories.tsx new file mode 100644 index 000000000000..099110d315c7 --- /dev/null +++ b/superset-frontend/src/components/Accessibility/StatusAnnouncer.stories.tsx @@ -0,0 +1,470 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from 'react'; +import { StatusAnnouncerProvider, useAnnouncer } from './StatusAnnouncer'; + +export default { + title: 'Components/Accessibility/StatusAnnouncer', + component: StatusAnnouncerProvider, + parameters: { + docs: { + description: { + component: + 'WCAG 4.1.3 Status Messages - ARIA live regions for screen reader announcements. ' + + 'Provides polite announcements for non-urgent updates and assertive announcements for urgent messages. ' + + 'Enable a screen reader (VoiceOver, NVDA, JAWS) to hear the announcements.', + }, + }, + }, +}; + +// Helper component for demos +const AnnouncerButtons = () => { + const { announcePolite, announceAssertive } = useAnnouncer(); + const [lastAction, setLastAction] = useState(''); + + const handlePolite = (message: string) => { + announcePolite(message); + setLastAction(`Polite: "${message}"`); + }; + + const handleAssertive = (message: string) => { + announceAssertive(message); + setLastAction(`Assertive: "${message}"`); + }; + + return ( +
+
+

Polite Announcements (non-urgent)

+
+ + + + +
+
+ +
+

Assertive Announcements (urgent)

+
+ + + +
+
+ + {lastAction && ( +
+ Last announcement: {lastAction} +
+ )} + +

+ Enable a screen reader to hear these announcements. Polite announcements wait for the + current speech to finish, while assertive announcements interrupt immediately. +

+
+ ); +}; + +// Interactive demo +export const InteractiveDemo = () => ( + + + +); + +InteractiveDemo.parameters = { + docs: { + description: { + story: + 'Interactive demo showing both polite and assertive announcements. ' + + 'Enable a screen reader to hear the announcements when buttons are clicked.', + }, + }, +}; + +// Real-world usage example +const RealWorldComponent = () => { + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const { announcePolite, announceAssertive } = useAnnouncer(); + + const handleSave = async () => { + setLoading(true); + setStatus('idle'); + announcePolite('Saving changes...'); + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Randomly succeed or fail for demo + if (Math.random() > 0.3) { + setStatus('success'); + announcePolite('Changes saved successfully'); + } else { + setStatus('error'); + announceAssertive('Error: Failed to save changes. Please try again.'); + } + setLoading(false); + }; + + const handleLoad = async () => { + setLoading(true); + announcePolite('Loading dashboard data...'); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + announcePolite('Dashboard loaded. 5 charts, 3 filters available.'); + setLoading(false); + }; + + return ( +
+

Dashboard Editor

+ +
+ + +
+ + {status === 'success' && ( +
+ Changes saved successfully! +
+ )} + + {status === 'error' && ( +
+ Failed to save changes. Please try again. +
+ )} + +

+ This example simulates real-world usage with loading states, success, and error scenarios. + Screen reader users will hear status updates without visual feedback. +

+
+ ); +}; + +export const RealWorldExample = () => ( + + + +); + +RealWorldExample.parameters = { + docs: { + description: { + story: + 'Real-world example showing how the announcer integrates with async operations. ' + + 'Demonstrates loading states, success messages, and error handling with appropriate announcement types.', + }, + }, +}; + +// Chart interaction example +const ChartInteractionComponent = () => { + const { announcePolite } = useAnnouncer(); + const [selectedPoints, setSelectedPoints] = useState(0); + + const handleChartClick = () => { + const newCount = selectedPoints + 1; + setSelectedPoints(newCount); + announcePolite(`${newCount} data point${newCount !== 1 ? 's' : ''} selected`); + }; + + const handleClearSelection = () => { + setSelectedPoints(0); + announcePolite('Selection cleared'); + }; + + return ( +
+

Chart Interaction Demo

+ +
e.key === 'Enter' && handleChartClick()} + role="button" + tabIndex={0} + style={{ + width: 300, + height: 200, + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + borderRadius: 8, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'white', + cursor: 'pointer', + marginTop: 16, + }} + > +
+
{selectedPoints}
+
Click to select data points
+
+
+ + + +

+ Click the chart area to simulate selecting data points. Screen reader users will hear + selection count updates. +

+
+ ); +}; + +export const ChartInteraction = () => ( + + + +); + +ChartInteraction.parameters = { + docs: { + description: { + story: + 'Demonstrates announcing chart interactions. ' + + 'Selection changes are announced to screen reader users.', + }, + }, +}; + +// Filter updates example +const FilterUpdatesComponent = () => { + const { announcePolite } = useAnnouncer(); + const [filters, setFilters] = useState([]); + + const availableFilters = ['Last 7 days', 'Last 30 days', 'This year', 'All time']; + + const toggleFilter = (filter: string) => { + if (filters.includes(filter)) { + const newFilters = filters.filter(f => f !== filter); + setFilters(newFilters); + announcePolite(`Filter removed: ${filter}. ${newFilters.length} filter${newFilters.length !== 1 ? 's' : ''} active.`); + } else { + const newFilters = [...filters, filter]; + setFilters(newFilters); + announcePolite(`Filter applied: ${filter}. ${newFilters.length} filter${newFilters.length !== 1 ? 's' : ''} active.`); + } + }; + + return ( +
+

Filter Panel

+ +
+ {availableFilters.map(filter => ( + + ))} +
+ +
+ Active filters: {filters.length > 0 ? filters.join(', ') : 'None'} +
+ +

+ Toggle filters to hear announcements about filter state changes. +

+
+ ); +}; + +export const FilterUpdates = () => ( + + + +); + +FilterUpdates.parameters = { + docs: { + description: { + story: + 'Shows how filter changes can be announced. ' + + 'Users are informed about active filters without needing to visually scan the UI.', + }, + }, +}; + +// Technical demo showing live regions +export const LiveRegionInspector = () => { + const { announcePolite, announceAssertive } = useAnnouncer(); + const [politeText, setPoliteText] = useState(''); + const [assertiveText, setAssertiveText] = useState(''); + + return ( + +
+

Live Region Inspector

+

+ This story shows the actual ARIA live regions. Use browser DevTools to inspect the + visually hidden elements. +

+ +
+
+ + setPoliteText(e.target.value)} + placeholder="Type a message..." + style={{ width: '100%', padding: 8 }} + /> + +
+ +
+ + setAssertiveText(e.target.value)} + placeholder="Type a message..." + style={{ width: '100%', padding: 8 }} + /> + +
+
+ +
+

ARIA Live Regions (inspect with DevTools)

+
+            {`
+ +
+ +`} +
+
+
+
+ ); +}; + +LiveRegionInspector.parameters = { + docs: { + description: { + story: + 'Technical demo showing the ARIA live regions used by the announcer. ' + + 'Use browser DevTools to inspect the visually hidden elements at the bottom of the DOM.', + }, + }, +}; diff --git a/superset-frontend/src/components/Accessibility/StatusAnnouncer.test.tsx b/superset-frontend/src/components/Accessibility/StatusAnnouncer.test.tsx new file mode 100644 index 000000000000..9d000010e8fb --- /dev/null +++ b/superset-frontend/src/components/Accessibility/StatusAnnouncer.test.tsx @@ -0,0 +1,657 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { act, render, screen, waitFor } from 'spec/helpers/testing-library'; +import { renderHook } from '@testing-library/react'; +import { StatusAnnouncerProvider, useAnnouncer } from './StatusAnnouncer'; + +// Helper component for testing announcer functions +const AnnouncerTestComponent = ({ + onMount, +}: { + onMount?: (announcer: ReturnType) => void; +}) => { + const announcer = useAnnouncer(); + if (onMount) { + onMount(announcer); + } + return
Test
; +}; + +describe('StatusAnnouncerProvider', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('Rendering', () => { + test('renders children unchanged', () => { + render( + +
Child Content
+
, + ); + expect(screen.getByTestId('child-content')).toBeInTheDocument(); + expect(screen.getByText('Child Content')).toBeInTheDocument(); + }); + + test('renders polite live region with role="status"', () => { + render( + +
Content
+
, + ); + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toBeInTheDocument(); + expect(politeRegion).toHaveAttribute('role', 'status'); + }); + + test('renders assertive live region with role="alert"', () => { + render( + +
Content
+
, + ); + const assertiveRegion = document.getElementById('a11y-alert-announcer'); + expect(assertiveRegion).toBeInTheDocument(); + expect(assertiveRegion).toHaveAttribute('role', 'alert'); + }); + + test('live regions are visually hidden', () => { + render( + +
Content
+
, + ); + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toHaveStyleRule('position', 'absolute'); + expect(politeRegion).toHaveStyleRule('width', '1px'); + expect(politeRegion).toHaveStyleRule('height', '1px'); + expect(politeRegion).toHaveStyleRule('overflow', 'hidden'); + }); + + test('live regions have correct IDs', () => { + render( + +
Content
+
, + ); + expect(document.getElementById('a11y-status-announcer')).toBeInTheDocument(); + expect(document.getElementById('a11y-alert-announcer')).toBeInTheDocument(); + }); + }); + + describe('ARIA Attributes', () => { + test('polite region has aria-live="polite"', () => { + render( + +
Content
+
, + ); + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toHaveAttribute('aria-live', 'polite'); + }); + + test('assertive region has aria-live="assertive"', () => { + render( + +
Content
+
, + ); + const assertiveRegion = document.getElementById('a11y-alert-announcer'); + expect(assertiveRegion).toHaveAttribute('aria-live', 'assertive'); + }); + + test('both regions have aria-atomic="true"', () => { + render( + +
Content
+
, + ); + const politeRegion = document.getElementById('a11y-status-announcer'); + const assertiveRegion = document.getElementById('a11y-alert-announcer'); + expect(politeRegion).toHaveAttribute('aria-atomic', 'true'); + expect(assertiveRegion).toHaveAttribute('aria-atomic', 'true'); + }); + + test('polite region has id="a11y-status-announcer"', () => { + render( + +
Content
+
, + ); + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toHaveAttribute('id', 'a11y-status-announcer'); + }); + + test('assertive region has id="a11y-alert-announcer"', () => { + render( + +
Content
+
, + ); + const assertiveRegion = document.getElementById('a11y-alert-announcer'); + expect(assertiveRegion).toHaveAttribute('id', 'a11y-alert-announcer'); + }); + }); + + describe('Polite Announcements', () => { + test('announcePolite updates polite region text', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + act(() => { + capturedAnnouncer!.announcePolite('Loading data...'); + jest.advanceTimersByTime(150); + }); + + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toHaveTextContent('Loading data...'); + }); + + test('polite announcement does not affect assertive region', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + act(() => { + capturedAnnouncer!.announcePolite('Status update'); + jest.advanceTimersByTime(150); + }); + + const assertiveRegion = document.getElementById('a11y-alert-announcer'); + expect(assertiveRegion).toHaveTextContent(''); + }); + + test('clears message before re-announcing same text', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + const politeRegion = document.getElementById('a11y-status-announcer'); + + // First announcement + act(() => { + capturedAnnouncer!.announcePolite('Same message'); + jest.advanceTimersByTime(150); + }); + expect(politeRegion).toHaveTextContent('Same message'); + + // Second announcement with same text - should clear first + act(() => { + capturedAnnouncer!.announcePolite('Same message'); + }); + expect(politeRegion).toHaveTextContent(''); + + act(() => { + jest.advanceTimersByTime(150); + }); + expect(politeRegion).toHaveTextContent('Same message'); + }); + + test('handles empty string announcement', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + act(() => { + capturedAnnouncer!.announcePolite(''); + jest.advanceTimersByTime(150); + }); + + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toHaveTextContent(''); + }); + + test('handles long text announcements', async () => { + let capturedAnnouncer: ReturnType; + const longText = + 'This is a very long announcement message that contains a lot of information about the current status of the application and should be read in its entirety by the screen reader.'; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + act(() => { + capturedAnnouncer!.announcePolite(longText); + jest.advanceTimersByTime(150); + }); + + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toHaveTextContent(longText); + }); + + test('handles special characters in message', async () => { + let capturedAnnouncer: ReturnType; + const specialMessage = 'Status: 100% complete! '; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + act(() => { + capturedAnnouncer!.announcePolite(specialMessage); + jest.advanceTimersByTime(150); + }); + + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toHaveTextContent(specialMessage); + }); + }); + + describe('Assertive Announcements', () => { + test('announceAssertive updates assertive region text', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + act(() => { + capturedAnnouncer!.announceAssertive('Error: Connection failed'); + jest.advanceTimersByTime(150); + }); + + const assertiveRegion = document.getElementById('a11y-alert-announcer'); + expect(assertiveRegion).toHaveTextContent('Error: Connection failed'); + }); + + test('assertive announcement does not affect polite region', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + act(() => { + capturedAnnouncer!.announceAssertive('Error occurred'); + jest.advanceTimersByTime(150); + }); + + const politeRegion = document.getElementById('a11y-status-announcer'); + expect(politeRegion).toHaveTextContent(''); + }); + + test('clears message before re-announcing same text', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + const assertiveRegion = document.getElementById('a11y-alert-announcer'); + + act(() => { + capturedAnnouncer!.announceAssertive('Error!'); + jest.advanceTimersByTime(150); + }); + expect(assertiveRegion).toHaveTextContent('Error!'); + + act(() => { + capturedAnnouncer!.announceAssertive('Error!'); + }); + expect(assertiveRegion).toHaveTextContent(''); + + act(() => { + jest.advanceTimersByTime(150); + }); + expect(assertiveRegion).toHaveTextContent('Error!'); + }); + + test('handles empty string announcement', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + act(() => { + capturedAnnouncer!.announceAssertive(''); + jest.advanceTimersByTime(150); + }); + + const assertiveRegion = document.getElementById('a11y-alert-announcer'); + expect(assertiveRegion).toHaveTextContent(''); + }); + }); + + describe('Timing Behavior', () => { + test('clears message with 100ms delay before re-announcing', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + const politeRegion = document.getElementById('a11y-status-announcer'); + + act(() => { + capturedAnnouncer!.announcePolite('First message'); + }); + + // Message should be cleared immediately + expect(politeRegion).toHaveTextContent(''); + + // After 50ms, still cleared + act(() => { + jest.advanceTimersByTime(50); + }); + expect(politeRegion).toHaveTextContent(''); + + // After 100ms, message appears + act(() => { + jest.advanceTimersByTime(50); + }); + expect(politeRegion).toHaveTextContent('First message'); + }); + + test('handles rapid successive announcements', async () => { + let capturedAnnouncer: ReturnType; + + render( + + { + capturedAnnouncer = announcer; + }} + /> + , + ); + + const politeRegion = document.getElementById('a11y-status-announcer'); + + act(() => { + capturedAnnouncer!.announcePolite('Message 1'); + capturedAnnouncer!.announcePolite('Message 2'); + capturedAnnouncer!.announcePolite('Message 3'); + jest.advanceTimersByTime(150); + }); + + // Last message should win + expect(politeRegion).toHaveTextContent('Message 3'); + }); + }); + + describe('Multiple Providers', () => { + test('nested providers work independently', () => { + render( + +
+ +
Inner Content
+
+
+
, + ); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + }); + + test('each provider has own state', async () => { + let outerAnnouncer: ReturnType; + let innerAnnouncer: ReturnType; + + render( + + { + outerAnnouncer = announcer; + }} + /> + + { + innerAnnouncer = announcer; + }} + /> + + , + ); + + // Both announcers should be available + expect(outerAnnouncer!.announcePolite).toBeDefined(); + expect(innerAnnouncer!.announcePolite).toBeDefined(); + }); + }); +}); + +describe('useAnnouncer Hook', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('Inside Provider', () => { + test('returns announcePolite function', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useAnnouncer(), { wrapper }); + + expect(result.current.announcePolite).toBeDefined(); + expect(typeof result.current.announcePolite).toBe('function'); + }); + + test('returns announceAssertive function', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useAnnouncer(), { wrapper }); + + expect(result.current.announceAssertive).toBeDefined(); + expect(typeof result.current.announceAssertive).toBe('function'); + }); + + test('functions are stable references (memoized)', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result, rerender } = renderHook(() => useAnnouncer(), { wrapper }); + + const firstPolite = result.current.announcePolite; + const firstAssertive = result.current.announceAssertive; + + rerender(); + + expect(result.current.announcePolite).toBe(firstPolite); + expect(result.current.announceAssertive).toBe(firstAssertive); + }); + }); + + describe('Outside Provider', () => { + test('returns no-op announcePolite function', () => { + const { result } = renderHook(() => useAnnouncer()); + + expect(result.current.announcePolite).toBeDefined(); + expect(typeof result.current.announcePolite).toBe('function'); + }); + + test('returns no-op announceAssertive function', () => { + const { result } = renderHook(() => useAnnouncer()); + + expect(result.current.announceAssertive).toBeDefined(); + expect(typeof result.current.announceAssertive).toBe('function'); + }); + + test('does not throw error', () => { + const { result } = renderHook(() => useAnnouncer()); + + expect(() => result.current.announcePolite('test')).not.toThrow(); + expect(() => result.current.announceAssertive('test')).not.toThrow(); + }); + + test('no-op functions can be called safely', () => { + const { result } = renderHook(() => useAnnouncer()); + + // These should not throw + result.current.announcePolite('test message'); + result.current.announceAssertive('test error'); + }); + }); + + describe('Integration', () => { + test('multiple components can use same announcer', () => { + const ComponentA = () => { + const { announcePolite } = useAnnouncer(); + return ( + + ); + }; + + const ComponentB = () => { + const { announcePolite } = useAnnouncer(); + return ( + + ); + }; + + render( + + + + , + ); + + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + }); + + test('announcements from different components work', async () => { + const ComponentA = () => { + const { announcePolite } = useAnnouncer(); + return ( + + ); + }; + + render( + + + , + ); + + const politeRegion = document.getElementById('a11y-status-announcer'); + const button = screen.getByTestId('button-a'); + + act(() => { + button.click(); + jest.advanceTimersByTime(150); + }); + + expect(politeRegion).toHaveTextContent('From Component A'); + }); + }); +}); diff --git a/superset-frontend/src/components/Accessibility/StatusAnnouncer.tsx b/superset-frontend/src/components/Accessibility/StatusAnnouncer.tsx new file mode 100644 index 000000000000..ffc39b26e808 --- /dev/null +++ b/superset-frontend/src/components/Accessibility/StatusAnnouncer.tsx @@ -0,0 +1,109 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; +import { styled } from '@superset-ui/core'; + +/** + * StatusAnnouncer - WCAG 4.1.3 Status Messages + * Provides ARIA live regions for screen reader announcements. + * - Polite region: for non-urgent status updates (loading, saving) + * - Assertive region: for urgent messages (errors, alerts) + */ + +const VisuallyHidden = styled.div` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +`; + +interface AnnouncerContextType { + announcePolite: (message: string) => void; + announceAssertive: (message: string) => void; +} + +const AnnouncerContext = createContext(null); + +export const useAnnouncer = (): AnnouncerContextType => { + const context = useContext(AnnouncerContext); + if (!context) { + // Return no-op functions if used outside provider + return { + announcePolite: () => {}, + announceAssertive: () => {}, + }; + } + return context; +}; + +interface StatusAnnouncerProps { + children: React.ReactNode; +} + +export const StatusAnnouncerProvider: React.FC = ({ children }) => { + const [politeMessage, setPoliteMessage] = useState(''); + const [assertiveMessage, setAssertiveMessage] = useState(''); + + const announcePolite = useCallback((message: string) => { + // Clear first to ensure re-announcement of same message + setPoliteMessage(''); + setTimeout(() => setPoliteMessage(message), 100); + }, []); + + const announceAssertive = useCallback((message: string) => { + setAssertiveMessage(''); + setTimeout(() => setAssertiveMessage(message), 100); + }, []); + + const contextValue = useMemo( + () => ({ announcePolite, announceAssertive }), + [announcePolite, announceAssertive] + ); + + return ( + + {children} + {/* Polite live region for status updates */} + + {politeMessage} + + {/* Assertive live region for alerts/errors */} + + {assertiveMessage} + + + ); +}; + +export default StatusAnnouncerProvider; diff --git a/superset-frontend/src/components/Accessibility/index.tsx b/superset-frontend/src/components/Accessibility/index.tsx new file mode 100644 index 000000000000..e9b0684803ba --- /dev/null +++ b/superset-frontend/src/components/Accessibility/index.tsx @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * 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 CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Accessibility Components for WCAG 2.1 Level A Compliance + * + * This module provides accessibility utilities for Apache Superset: + * - SkipLink: WCAG 2.4.1 - Bypass Blocks + * - StatusAnnouncer: WCAG 4.1.3 - Status Messages + */ + +export { default as SkipLink } from './SkipLink'; +export { StatusAnnouncerProvider, useAnnouncer } from './StatusAnnouncer'; diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 4e76ec6fe615..051c77bc3782 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { +import React, { forwardRef, ReactNode, useContext, @@ -24,22 +24,19 @@ import { useRef, useState, } from 'react'; -import { t } from '@apache-superset/core'; -import { getExtensionsRegistry, QueryData } from '@superset-ui/core'; -import { css, styled, SupersetTheme, useTheme } from '@apache-superset/core/ui'; +import { css, getExtensionsRegistry, styled, t } from '@superset-ui/core'; import { useUiConfig } from 'src/components/UiConfigContext'; -import { isEmbedded } from 'src/dashboard/util/isEmbedded'; -import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components'; +import { Tooltip } from 'src/components/Tooltip'; import { useSelector } from 'react-redux'; -import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls'; -import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types'; +import EditableTitle from 'src/components/EditableTitle'; +import SliceHeaderControls, { + SliceHeaderControlsProps, +} from 'src/dashboard/components/SliceHeaderControls'; import FiltersBadge from 'src/dashboard/components/FiltersBadge'; -import CustomizationsBadge from 'src/dashboard/components/CustomizationsBadge'; +import Icons from 'src/components/Icons'; import { RootState } from 'src/dashboard/types'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; -import RowCountLabel from 'src/components/RowCountLabel'; -import { Link } from 'react-router-dom'; const extensionsRegistry = getExtensionsRegistry(); @@ -54,8 +51,6 @@ type SliceHeaderProps = SliceHeaderControlsProps & { formData: object; width: number; height: number; - queriedDttm?: string | null; - exportPivotExcel?: (arg0: string) => void; }; const annotationsLoading = t('Annotation layers are still loading.'); @@ -63,25 +58,26 @@ const annotationsError = t('One or more annotation layers failed loading.'); const CrossFilterIcon = styled(Icons.ApartmentOutlined)` ${({ theme }) => ` cursor: default; - color: ${theme.colorPrimary}; + color: ${theme.colors.primary.base}; line-height: 1.8; `} `; const ChartHeaderStyles = styled.div` ${({ theme }) => css` - font-size: ${theme.fontSizeLG}px; - font-weight: ${theme.fontWeightStrong}; - margin-bottom: ${theme.sizeUnit}px; + font-size: ${theme.typography.sizes.l}px; + font-weight: ${theme.typography.weights.bold}; + margin-bottom: ${theme.gridUnit}px; display: flex; max-width: 100%; align-items: flex-start; min-height: 0; + /* WCAG 1.3.1: Chart title as semantic heading */ & > .header-title { overflow: hidden; text-overflow: ellipsis; - max-width: calc(100% - ${theme.sizeUnit * 4}px); + max-width: 100%; flex-grow: 1; display: -webkit-box; -webkit-line-clamp: 2; @@ -90,12 +86,24 @@ const ChartHeaderStyles = styled.div` & > span.ant-tooltip-open { display: inline; } + + /* Style the h2 to match the original design */ + & h2 { + font-size: inherit; + font-weight: inherit; + margin: 0; + line-height: inherit; + } } & > .header-controls { display: flex; align-items: center; height: 24px; + + & > * { + margin-left: ${theme.gridUnit * 2}px; + } } .dropdown.btn-group { @@ -113,18 +121,18 @@ const ChartHeaderStyles = styled.div` } .dropdown-menu.dropdown-menu-right { - top: ${theme.sizeUnit * 5}px; + top: ${theme.gridUnit * 5}px; } .divider { - margin: ${theme.sizeUnit}px 0; + margin: ${theme.gridUnit}px 0; } .refresh-tooltip { display: block; - height: ${theme.sizeUnit * 4}px; - margin: ${theme.sizeUnit}px 0; - color: ${theme.colorTextLabel}; + height: ${theme.gridUnit * 4}px; + margin: ${theme.gridUnit}px 0; + color: ${theme.colors.text.label}; } `} `; @@ -143,7 +151,6 @@ const SliceHeader = forwardRef( annotationQuery = {}, annotationError = {}, cachedDttm = null, - queriedDttm = null, updatedDttm = null, isCached = [], isExpanded = false, @@ -151,7 +158,6 @@ const SliceHeader = forwardRef( supersetCanExplore = false, supersetCanShare = false, supersetCanCSV = false, - exportPivotCSV, exportFullCSV, exportFullXLSX, slice, @@ -165,200 +171,147 @@ const SliceHeader = forwardRef( formData, width, height, - exportPivotExcel = () => ({}), }, ref, ) => { - const SliceHeaderExtension = extensionsRegistry.get( - 'dashboard.slice.header', - ); - const uiConfig = useUiConfig(); - const shouldShowRowLimitWarning = - !isEmbedded() || uiConfig.showRowLimitWarning; - const dashboardPageId = useContext(DashboardPageIdContext); - const [headerTooltip, setHeaderTooltip] = useState(null); - const headerRef = useRef(null); - // TODO: change to indicator field after it will be implemented - const crossFilterValue = useSelector( - state => state.dataMask[slice?.slice_id]?.filterState?.value, - ); - const isCrossFiltersEnabled = useSelector( - ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, - ); - - const firstQueryResponse = useSelector( - state => state.charts[slice.slice_id].queriesResponse?.[0], - ); - - const theme = useTheme(); - - const rowLimit = Number(formData.row_limit || -1); - const sqlRowCount = Number(firstQueryResponse?.sql_rowcount || 0); + const SliceHeaderExtension = extensionsRegistry.get('dashboard.slice.header'); + const uiConfig = useUiConfig(); + const dashboardPageId = useContext(DashboardPageIdContext); + const [headerTooltip, setHeaderTooltip] = useState(null); + const headerRef = useRef(null); + // TODO: change to indicator field after it will be implemented + const crossFilterValue = useSelector( + state => state.dataMask[slice?.slice_id]?.filterState?.value, + ); + const isCrossFiltersEnabled = useSelector( + ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, + ); - const canExplore = !editMode && supersetCanExplore; + const canExplore = !editMode && supersetCanExplore; - useEffect(() => { - const headerElement = headerRef.current; - if (canExplore) { - setHeaderTooltip(getSliceHeaderTooltip(sliceName)); - } else if ( - headerElement && - (headerElement.scrollWidth > headerElement.offsetWidth || - headerElement.scrollHeight > headerElement.offsetHeight) - ) { - setHeaderTooltip(sliceName ?? null); - } else { - setHeaderTooltip(null); - } - }, [sliceName, width, height, canExplore]); - - const exploreUrl = `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${slice.slice_id}`; + useEffect(() => { + const headerElement = headerRef.current; + if (canExplore) { + setHeaderTooltip(getSliceHeaderTooltip(sliceName)); + } else if ( + headerElement && + (headerElement.scrollWidth > headerElement.offsetWidth || + headerElement.scrollHeight > headerElement.offsetHeight) + ) { + setHeaderTooltip(sliceName ?? null); + } else { + setHeaderTooltip(null); + } + }, [sliceName, width, height, canExplore]); - const renderExploreLink = (title: string) => ( - css` - color: ${theme.colorText}; - text-decoration: none; - :hover { - text-decoration: underline; - } - display: inline-block; - `} - > - {title} - - ); + const exploreUrl = `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${slice.slice_id}`; - return ( - -
+ return ( + +
+ {/* WCAG 1.3.1: Chart title as semantic heading for screen readers */} +

- {/* this div ensures the hover event triggers correctly and prevents flickering */} -
- -
+
- {!!Object.values(annotationQuery).length && ( - - + {!!Object.values(annotationQuery).length && ( + + + + )} + {!!Object.values(annotationError).length && ( + + + + )} +

+
+ {!editMode && ( + <> + {SliceHeaderExtension && ( + - - )} - {!!Object.values(annotationError).length && ( - - + + + )} + {!uiConfig.hideChartControls && ( + + )} + {!uiConfig.hideChartControls && ( + - - )} -
-
- {!editMode && ( - <> - {SliceHeaderExtension && ( - - )} - {crossFilterValue && ( - - - - )} - {!uiConfig.hideChartControls && ( - - )} - - {!uiConfig.hideChartControls && ( - - )} - - {shouldShowRowLimitWarning && sqlRowCount === rowLimit && ( - css` - padding: ${theme.sizeUnit}px; - `} - /> - } - /> - )} - {!uiConfig.hideChartControls && ( - - )} - - )} -
-
- ); - }, + )} + + )} +
+
+ ); +}, ); export default SliceHeader; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx index 24fe7a30a1e0..118c75af1b4f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/ActionButtons/index.tsx @@ -16,19 +16,19 @@ * specific language governing permissions and limitations * under the License. */ -import { useMemo } from 'react'; -import { t } from '@apache-superset/core'; +import React, { useMemo } from 'react'; import { + css, DataMaskState, DataMaskStateWithId, + t, isDefined, - ChartCustomization, - ChartCustomizationDivider, + SupersetTheme, } from '@superset-ui/core'; -import { css, SupersetTheme, styled } from '@apache-superset/core/ui'; -import { Button } from '@superset-ui/core/components'; +import Button from 'src/components/Button'; +import { Tooltip } from 'src/components/Tooltip'; import { OPEN_FILTER_BAR_WIDTH } from 'src/dashboard/constants'; -import tinycolor from 'tinycolor2'; +import { rgba } from 'emotion-rgba'; import { FilterBarOrientation } from 'src/dashboard/types'; import { getFilterBarTestId } from '../utils'; @@ -38,7 +38,6 @@ interface ActionButtonsProps { onClearAll: () => void; dataMaskSelected: DataMaskState; dataMaskApplied: DataMaskStateWithId; - chartCustomizationItems?: (ChartCustomization | ChartCustomizationDivider)[]; isApplyDisabled: boolean; filterBarOrientation?: FilterBarOrientation; } @@ -47,15 +46,15 @@ const containerStyle = (theme: SupersetTheme) => css` display: flex; && > .filter-clear-all-button { - color: ${theme.colorTextSecondary}; + color: ${theme.colors.grayscale.base}; margin-left: 0; &:hover { - color: ${theme.colorPrimaryText}; + color: ${theme.colors.primary.dark1}; } &[disabled], &[disabled]:hover { - color: ${theme.colorTextDisabled}; + color: ${theme.colors.grayscale.light1}; } } `; @@ -63,6 +62,7 @@ const containerStyle = (theme: SupersetTheme) => css` const verticalStyle = (theme: SupersetTheme, width: number) => css` flex-direction: column; align-items: center; + pointer-events: none; position: fixed; z-index: 100; @@ -70,16 +70,20 @@ const verticalStyle = (theme: SupersetTheme, width: number) => css` width: ${width - 1}px; bottom: 0; - padding: ${theme.sizeUnit * 4}px; - padding-top: ${theme.sizeUnit * 6}px; + padding: ${theme.gridUnit * 4}px; + padding-top: ${theme.gridUnit * 6}px; background: linear-gradient( - ${tinycolor(theme.colorBgLayout).setAlpha(0).toRgbString()}, - ${theme.colorBgContainer} 20% + ${rgba(theme.colors.grayscale.light5, 0)}, + ${theme.colors.grayscale.light5} ${theme.opacity.mediumLight} ); + & > button { + pointer-events: auto; + } + & > .filter-apply-button { - margin-bottom: ${theme.sizeUnit * 3}px; + margin-bottom: ${theme.gridUnit * 3}px; } `; @@ -88,15 +92,15 @@ const horizontalStyle = (theme: SupersetTheme) => css` margin-left: auto; && > .filter-clear-all-button { text-transform: capitalize; - font-weight: ${theme.fontWeightNormal}; + font-weight: ${theme.typography.weights.normal}; + } + & > .filter-apply-button { + &[disabled], + &[disabled]:hover { + color: ${theme.colors.grayscale.light1}; + background: ${theme.colors.grayscale.light3}; + } } -`; - -const ButtonsContainer = styled.div<{ isVertical: boolean; width: number }>` - ${({ theme, isVertical, width }) => css` - ${containerStyle(theme)}; - ${isVertical ? verticalStyle(theme, width) : horizontalStyle(theme)}; - `} `; const ActionButtons = ({ @@ -107,63 +111,55 @@ const ActionButtons = ({ dataMaskSelected, isApplyDisabled, filterBarOrientation = FilterBarOrientation.Vertical, - chartCustomizationItems, }: ActionButtonsProps) => { const isClearAllEnabled = useMemo(() => { - const hasSelectedChanges = Object.entries(dataMaskSelected).some( - ([, mask]) => { - const hasValue = isDefined(mask?.filterState?.value); - const hasGroupBy = isDefined(mask?.ownState?.column); - return hasValue || hasGroupBy; - }, + // Check both selected (pending) and applied filters to determine if clear is available + const hasSelectedValues = Object.values(dataMaskSelected).some( + mask => isDefined(mask.filterState?.value), ); - - const hasAppliedChanges = Object.entries(dataMaskApplied).some( - ([, mask]) => { - const hasValue = isDefined(mask?.filterState?.value); - const hasGroupBy = isDefined(mask?.ownState?.column); - return hasValue || hasGroupBy; - }, + const hasAppliedValues = Object.values(dataMaskApplied).some( + mask => isDefined(mask.filterState?.value), ); - - const hasChartCustomizations = chartCustomizationItems?.some(item => { - if (item.removed) return false; - const mask = dataMaskApplied[item.id] || dataMaskSelected[item.id]; - const hasValue = isDefined(mask?.filterState?.value); - const hasGroupBy = isDefined(mask?.ownState?.column); - return hasValue || hasGroupBy; - }); - - return hasSelectedChanges || hasAppliedChanges || hasChartCustomizations; - }, [dataMaskSelected, dataMaskApplied, chartCustomizationItems]); + return hasSelectedValues || hasAppliedValues; + }, [dataMaskSelected, dataMaskApplied]); const isVertical = filterBarOrientation === FilterBarOrientation.Vertical; return ( - [ + containerStyle(theme), + isVertical ? verticalStyle(theme, width) : horizontalStyle(theme), + ]} data-test="filterbar-action-buttons" > - + + + + - + ); };