This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch feat-granular-theming
in repository https://gitbox.apache.org/repos/asf/superset.git

commit eb77167bae5dc0a274332aa1e11185f6fcec62c1
Author: Evan Rusackas <[email protected]>
AuthorDate: Mon Dec 15 16:50:34 2025 -0800

    feat(dashboard): add ComponentHeaderControls for consistent component menus
    
    Create a generic menu component for dashboard components (Markdown, Row,
    Column, Tab) that provides a consistent vertical dots menu pattern,
    matching the existing SliceHeaderControls used for charts.
    
    New files:
    - ComponentHeaderControls/index.tsx: Main component with:
      - NoAnimationDropdown + Menu pattern from @superset-ui/core
      - ComponentMenuKeys enum for standard actions (Delete, ApplyTheme, etc.)
      - Configurable visibility (editMode, showInViewMode)
    
    - ComponentHeaderControls/useComponentMenuItems.tsx: Helper hook that:
      - Builds standard menu items (theme selection, delete)
      - Supports custom items before/after standard items
      - Shows applied theme name in menu
    
    This is the foundation for adding granular theming to all dashboard
    components with a consistent UI pattern.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-Authored-By: Claude Opus 4.5 <[email protected]>
---
 docs/GRANULAR_THEMING_PLAN.md                      |  19 ++
 .../menu/ComponentHeaderControls/index.tsx         | 193 +++++++++++++++++++++
 .../useComponentMenuItems.tsx                      | 130 ++++++++++++++
 3 files changed, 342 insertions(+)

diff --git a/docs/GRANULAR_THEMING_PLAN.md b/docs/GRANULAR_THEMING_PLAN.md
index 53d76df156c..47b0d5e09c9 100644
--- a/docs/GRANULAR_THEMING_PLAN.md
+++ b/docs/GRANULAR_THEMING_PLAN.md
@@ -344,3 +344,22 @@ _Ongoing notes as we implement..._
 - Decided on modal with live preview for theme selection
 - Clarified color scheme (data) vs theme (UI) separation
 
+### Session 1 - Implementation Started
+- Created `ComponentHeaderControls` component at:
+  `src/dashboard/components/menu/ComponentHeaderControls/index.tsx`
+  - Generic vertical dots menu matching SliceHeaderControls pattern
+  - Uses NoAnimationDropdown + Menu from @superset-ui/core
+  - Configurable menu items, edit mode visibility
+  - Exports `ComponentMenuKeys` enum for standard actions
+
+- Created `useComponentMenuItems` hook at:
+  
`src/dashboard/components/menu/ComponentHeaderControls/useComponentMenuItems.tsx`
+  - Builds standard menu items (theme, delete)
+  - Supports custom items before/after standard items
+  - Shows "Change theme (name)" when theme applied
+
+**Next Steps:**
+1. Integrate ComponentHeaderControls into Markdown component
+2. Test with simple Edit/Preview + Theme + Delete menu
+3. Remove old MarkdownModeDropdown
+
diff --git 
a/superset-frontend/src/dashboard/components/menu/ComponentHeaderControls/index.tsx
 
b/superset-frontend/src/dashboard/components/menu/ComponentHeaderControls/index.tsx
new file mode 100644
index 00000000000..0eb91d1fd8f
--- /dev/null
+++ 
b/superset-frontend/src/dashboard/components/menu/ComponentHeaderControls/index.tsx
@@ -0,0 +1,193 @@
+/**
+ * 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, Key, MouseEvent, KeyboardEvent } from 'react';
+import { t } from '@superset-ui/core';
+import { css, useTheme } from '@apache-superset/core/ui';
+import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
+import {
+  NoAnimationDropdown,
+  Button,
+} from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+
+/**
+ * Standard menu keys for dashboard components.
+ * Components can use these standard keys or define custom ones.
+ */
+export enum ComponentMenuKeys {
+  // Common actions
+  Delete = 'delete',
+  Edit = 'edit',
+
+  // Theme actions
+  ApplyTheme = 'apply-theme',
+  ClearTheme = 'clear-theme',
+
+  // Markdown-specific
+  EditContent = 'edit-content',
+  PreviewContent = 'preview-content',
+
+  // Row/Column-specific
+  BackgroundStyle = 'background-style',
+
+  // Tab-specific
+  RenameTab = 'rename-tab',
+}
+
+// Re-export MenuItem type for convenience - allows both keyed items and 
dividers
+export type ComponentMenuItem = MenuItem;
+
+export interface ComponentHeaderControlsProps {
+  /** Unique identifier for the component */
+  componentId: string;
+
+  /** Array of menu items to display */
+  menuItems: ComponentMenuItem[];
+
+  /** Callback when a menu item is clicked */
+  onMenuClick: (key: string, domEvent: MouseEvent<HTMLElement> | 
KeyboardEvent<HTMLElement>) => void;
+
+  /** Whether the component is in edit mode */
+  editMode?: boolean;
+
+  /** Whether to show the menu even in view mode */
+  showInViewMode?: boolean;
+
+  /** Z-index for the dropdown overlay */
+  zIndex?: number;
+
+  /** Additional CSS class for the trigger button */
+  className?: string;
+
+  /** Whether the menu is disabled */
+  disabled?: boolean;
+}
+
+const VerticalDotsTrigger = () => {
+  const theme = useTheme();
+  return (
+    <Icons.EllipsisOutlined
+      css={css`
+        transform: rotate(90deg);
+        &:hover {
+          cursor: pointer;
+        }
+      `}
+      iconSize="l"
+      iconColor={theme.colorTextLabel}
+      className="component-menu-trigger"
+    />
+  );
+};
+
+/**
+ * A standardized menu component for dashboard components (Markdown, Row, 
Column, Tab).
+ *
+ * Provides a consistent vertical dots menu pattern similar to 
SliceHeaderControls,
+ * but generic enough to be used across all dashboard component types.
+ *
+ * Usage:
+ * ```tsx
+ * <ComponentHeaderControls
+ *   componentId="MARKDOWN-123"
+ *   menuItems={[
+ *     { key: ComponentMenuKeys.Edit, label: t('Edit') },
+ *     { key: ComponentMenuKeys.ApplyTheme, label: t('Apply theme') },
+ *     { type: 'divider' },
+ *     { key: ComponentMenuKeys.Delete, label: t('Delete'), danger: true },
+ *   ]}
+ *   onMenuClick={(key) => handleMenuAction(key)}
+ *   editMode={editMode}
+ * />
+ * ```
+ */
+const ComponentHeaderControls = ({
+  componentId,
+  menuItems,
+  onMenuClick,
+  editMode = false,
+  showInViewMode = false,
+  zIndex = 99,
+  className,
+  disabled = false,
+}: ComponentHeaderControlsProps) => {
+  const [isDropdownVisible, setIsDropdownVisible] = useState(false);
+  const theme = useTheme();
+
+  // Don't render if not in edit mode and showInViewMode is false
+  if (!editMode && !showInViewMode) {
+    return null;
+  }
+
+  const handleMenuClick = ({
+    key,
+    domEvent,
+  }: {
+    key: Key;
+    domEvent: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>;
+  }) => {
+    onMenuClick(String(key), domEvent);
+    setIsDropdownVisible(false);
+  };
+
+  const dropdownOverlayStyle = {
+    zIndex,
+    animationDuration: '0s',
+  };
+
+  return (
+    <NoAnimationDropdown
+      popupRender={() => (
+        <Menu
+          onClick={handleMenuClick}
+          data-test={`component-menu-${componentId}`}
+          id={`component-menu-${componentId}`}
+          selectable={false}
+          items={menuItems}
+        />
+      )}
+      overlayStyle={dropdownOverlayStyle}
+      trigger={['click']}
+      placement="bottomRight"
+      open={isDropdownVisible}
+      onOpenChange={visible => setIsDropdownVisible(visible)}
+      disabled={disabled}
+    >
+      <Button
+        id={`${componentId}-controls`}
+        buttonStyle="link"
+        aria-label={t('More Options')}
+        aria-haspopup="true"
+        className={className}
+        css={css`
+          padding: ${theme.sizeUnit}px;
+          opacity: 0.7;
+          transition: opacity 0.2s;
+          &:hover {
+            opacity: 1;
+          }
+        `}
+      >
+        <VerticalDotsTrigger />
+      </Button>
+    </NoAnimationDropdown>
+  );
+};
+
+export default ComponentHeaderControls;
diff --git 
a/superset-frontend/src/dashboard/components/menu/ComponentHeaderControls/useComponentMenuItems.tsx
 
b/superset-frontend/src/dashboard/components/menu/ComponentHeaderControls/useComponentMenuItems.tsx
new file mode 100644
index 00000000000..de2acfde799
--- /dev/null
+++ 
b/superset-frontend/src/dashboard/components/menu/ComponentHeaderControls/useComponentMenuItems.tsx
@@ -0,0 +1,130 @@
+/**
+ * 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 { useMemo } from 'react';
+import { t } from '@superset-ui/core';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { css } from '@apache-superset/core/ui';
+import { MenuItem } from '@superset-ui/core/components/Menu';
+import { ComponentMenuKeys } from './index';
+
+const dropdownIconsStyles = css`
+  &&.anticon > .anticon:first-child {
+    margin-right: 0;
+    vertical-align: 0;
+  }
+`;
+
+export interface UseComponentMenuItemsOptions {
+  /** Whether to include theme menu item */
+  includeTheme?: boolean;
+
+  /** Whether to include delete menu item */
+  includeDelete?: boolean;
+
+  /** Whether the component has a theme applied */
+  hasThemeApplied?: boolean;
+
+  /** Name of the applied theme (for display) */
+  appliedThemeName?: string;
+
+  /** Additional custom menu items to include before standard items */
+  customItems?: MenuItem[];
+
+  /** Additional custom menu items to include after standard items */
+  customItemsAfter?: MenuItem[];
+}
+
+/**
+ * Hook to build standard menu items for dashboard components.
+ *
+ * Provides consistent menu item structure across all component types,
+ * with optional theme selection and delete actions.
+ */
+export function useComponentMenuItems({
+  includeTheme = true,
+  includeDelete = true,
+  hasThemeApplied = false,
+  appliedThemeName,
+  customItems = [],
+  customItemsAfter = [],
+}: UseComponentMenuItemsOptions = {}): MenuItem[] {
+  return useMemo(() => {
+    const items: MenuItem[] = [];
+
+    // Add custom items first
+    if (customItems.length > 0) {
+      items.push(...customItems);
+    }
+
+    // Add theme items
+    if (includeTheme) {
+      if (items.length > 0) {
+        items.push({ type: 'divider' });
+      }
+
+      items.push({
+        key: ComponentMenuKeys.ApplyTheme,
+        label: hasThemeApplied
+          ? t('Change theme (%s)', appliedThemeName || t('Custom'))
+          : t('Apply theme'),
+        icon: <Icons.BgColorsOutlined css={dropdownIconsStyles} />,
+      });
+
+      if (hasThemeApplied) {
+        items.push({
+          key: ComponentMenuKeys.ClearTheme,
+          label: t('Clear theme'),
+          icon: <Icons.ClearOutlined css={dropdownIconsStyles} />,
+        });
+      }
+    }
+
+    // Add custom items after theme
+    if (customItemsAfter.length > 0) {
+      if (items.length > 0) {
+        items.push({ type: 'divider' });
+      }
+      items.push(...customItemsAfter);
+    }
+
+    // Add delete as last item
+    if (includeDelete) {
+      if (items.length > 0) {
+        items.push({ type: 'divider' });
+      }
+      items.push({
+        key: ComponentMenuKeys.Delete,
+        label: t('Delete'),
+        icon: <Icons.DeleteOutlined css={dropdownIconsStyles} />,
+        danger: true,
+      });
+    }
+
+    return items;
+  }, [
+    includeTheme,
+    includeDelete,
+    hasThemeApplied,
+    appliedThemeName,
+    customItems,
+    customItemsAfter,
+  ]);
+}
+
+export default useComponentMenuItems;

Reply via email to