Skip to content

feat: add icon details modal#108

Draft
vjo wants to merge 11 commits intodevelopfrom
vjo/feat/add-icon-info
Draft

feat: add icon details modal#108
vjo wants to merge 11 commits intodevelopfrom
vjo/feat/add-icon-info

Conversation

@vjo
Copy link
Copy Markdown
Collaborator

@vjo vjo commented Apr 16, 2026

PR Checklist

Please check if your PR fulfills the following requirements:

Problem

For each icon in the icon gallery, we would like to let user see details about the icon such as tags and easily copy the name or custom element tag with the right name to use in their projects.

Solution

Add a icon details modal

Demo

Visit the icon gallery page and click on an icon:
https://infomaniak.github.io/design-system/storybook/mr/108/?path=/docs/icons-icon-gallery--docs&collection=esds

vjo added 9 commits April 15, 2026 15:05
Add CopyField component for displaying copyable values with visual feedback:
- Clipboard API integration via navigator.clipboard.writeText
- Tooltip feedback showing 'Copied!' or 'Copy failed' states
- Monospace font option for code values
- 1.5 second auto-hide timeout for feedback
- Full test coverage with 5 test cases
- Accessible with aria-label and role=status

Also add test setup file with jest-dom matchers and cleanup.
@vjo vjo requested a review from lifaon74 April 16, 2026 06:03
@vjo vjo self-assigned this Apr 16, 2026
@github-actions github-actions bot requested a review from lebojo April 16, 2026 06:03
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

⏭️ Storybook build skipped

  • Reason: pull request is still in draft
  • Changed files inspected: 16
  • Workflow run: View details

Storybook build was intentionally skipped for this PR event.

Relevant files

  • apps/docs/src/components/CopyableText.test.tsx
  • apps/docs/src/components/CopyableText.tsx
  • apps/docs/src/components/IconCard.test.tsx
  • apps/docs/src/components/IconCard.tsx
  • apps/docs/src/components/IconDetailModal.test.tsx
  • ...and 11 more

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 16, 2026

PR Reviewer Guide 🔍

(Review updated until commit 540f34d)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Memory Leak

The setTimeout used to reset copy status (line 55-57) is not cleared when the component unmounts. If the component unmounts while the timeout is pending (within 1500ms after copying), React will attempt to update state on an unmounted component, causing a memory leak warning. Add a useEffect cleanup function to clear hideTimeoutRef.current.

const [copyStatus, setCopyStatus] = useState<CopyStatus>('idle');
const [isHovered, setIsHovered] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);

const handleCopy = useCallback(
  async (event: React.MouseEvent<HTMLButtonElement>) => {
    // Calculate tooltip position
    const rect = event.currentTarget.getBoundingClientRect();
    setTooltipPosition({
      x: rect.left + rect.width / 2,
      y: rect.top - 8,
    });

    try {
      await navigator.clipboard.writeText(value);
      setCopyStatus('copied');
    } catch {
      setCopyStatus('failed');
    }

    // Clear any existing timeout
    if (hideTimeoutRef.current) {
      clearTimeout(hideTimeoutRef.current);
    }

    // Reset status after delay
    hideTimeoutRef.current = setTimeout(() => {
      setCopyStatus('idle');
    }, COPY_FEEDBACK_DURATION_MS);
  },
  [value],
);
Focus Trap Missing

The modal dialog lacks focus management. Keyboard users can tab out of the modal while it is open, and focus is not restored to the trigger element when the modal closes. This violates accessibility guidelines (WCAG 2.4.3). Implement focus trapping within the modal and restore focus to the triggering IconCard on close.

  const portalTarget = document.getElementById('modal-root') || document.body;

  return createPortal(
    <div
      data-testid="modal-backdrop"
      onClick={handleBackdropClick}
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        backgroundColor: 'rgba(0, 0, 0, 0.5)',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        zIndex: 1000,
      }}
    >
      <div
        onClick={handleModalClick}
        role="dialog"
        aria-modal="true"
        aria-label="Icon details"
        style={{
          backgroundColor: 'white',
          borderRadius: '8px',
          padding: '24px',
          maxWidth: '600px',
          width: '90%',
          maxHeight: '90vh',
          overflow: 'auto',
          position: 'relative',
        }}
      >
        <button
          onClick={onClose}
          aria-label="Close dialog"
          style={{
            position: 'absolute',
            top: '16px',
            right: '16px',
            background: 'none',
            border: 'none',
            cursor: 'pointer',
            fontSize: '20px',
          }}
        >
          ×
        </button>

        <div style={{ width: '96px', height: '96px' }}>
          <esds-svg name={iconName} />
        </div>

        <div style={{ marginBottom: '16px' }}>
          <CopyableText
            value={iconName}
            label=""
            size="lg"
          />
        </div>
        <div style={{ marginBottom: '16px' }}>
          <CopyableText
            value={esdsSnippet}
            label="Component"
          />
        </div>

        {metadata && <IconMetadataDisplay metadata={metadata} />}
      </div>
    </div>,
    portalTarget,
  );
}
Stale Data Risk

The useEffect fetching icon metadata (lines 14-35) does not handle component unmount or rapid icon changes. If the component unmounts or the user switches icons while the fetch is pending, the promise may resolve and set stale data or attempt to update an unmounted component. Use an AbortController or cleanup flag to ignore stale responses.

useEffect(() => {
  if (isOpen && icon) {
    // Fetch metadata from Iconify API with collection info
    iconifyApi
      .listIcons({ prefix, info: true })
      .then(({ icons, info }) => {
        const foundIcon = icons.find((i) => i.name === icon.name);
        if (foundIcon) {
          setMetadata({
            name: foundIcon.name,
            iconId: `${prefix}:${foundIcon.name}`,
            tags: Array.from(foundIcon.categories || new Set()),
            collection: info?.name ?? prefix,
            license: info?.license?.title ?? 'Unknown License',
          });
        } else {
          setMetadata(null);
        }
      })
      .catch(() => {
        setMetadata(null);
      });
  } else {
    setMetadata(null);
  }
}, [isOpen, icon, prefix]);

@vjo vjo marked this pull request as draft April 16, 2026 06:07
@github-actions
Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 540f34d

Comment on lines +30 to +61
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);

const handleCopy = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
// Calculate tooltip position
const rect = event.currentTarget.getBoundingClientRect();
setTooltipPosition({
x: rect.left + rect.width / 2,
y: rect.top - 8,
});

try {
await navigator.clipboard.writeText(value);
setCopyStatus('copied');
} catch {
setCopyStatus('failed');
}

// Clear any existing timeout
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}

// Reset status after delay
hideTimeoutRef.current = setTimeout(() => {
setCopyStatus('idle');
}, COPY_FEEDBACK_DURATION_MS);
},
[value],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: Add a useEffect cleanup to clear the pending timeout when the component unmounts. This prevents memory leaks and state updates on unmounted components if the user navigates away while the copied feedback tooltip is still visible. [possible issue, importance: 7]

Suggested change
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const handleCopy = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
// Calculate tooltip position
const rect = event.currentTarget.getBoundingClientRect();
setTooltipPosition({
x: rect.left + rect.width / 2,
y: rect.top - 8,
});
try {
await navigator.clipboard.writeText(value);
setCopyStatus('copied');
} catch {
setCopyStatus('failed');
}
// Clear any existing timeout
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
// Reset status after delay
hideTimeoutRef.current = setTimeout(() => {
setCopyStatus('idle');
}, COPY_FEEDBACK_DURATION_MS);
},
[value],
);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
const handleCopy = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
// ... existing code ...
hideTimeoutRef.current = setTimeout(() => {
setCopyStatus('idle');
}, COPY_FEEDBACK_DURATION_MS);
},
[value],
);

Comment on lines +11 to +36
useEffect(() => {
if (isOpen && icon) {
// Fetch metadata from Iconify API with collection info
iconifyApi
.listIcons({ prefix, info: true })
.then(({ icons, info }) => {
const foundIcon = icons.find((i) => i.name === icon.name);
if (foundIcon) {
setMetadata({
name: foundIcon.name,
iconId: `${prefix}:${foundIcon.name}`,
tags: Array.from(foundIcon.categories || new Set()),
collection: info?.name ?? prefix,
license: info?.license?.title ?? 'Unknown License',
});
} else {
setMetadata(null);
}
})
.catch(() => {
setMetadata(null);
});
} else {
setMetadata(null);
}
}, [isOpen, icon, prefix]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: Prevent state updates on unmounted components by tracking the mounted state or using an AbortController. The async listIcons call may complete after the modal closes or unmounts, causing React warnings or memory leaks. [possible issue, importance: 7]

Suggested change
useEffect(() => {
if (isOpen && icon) {
// Fetch metadata from Iconify API with collection info
iconifyApi
.listIcons({ prefix, info: true })
.then(({ icons, info }) => {
const foundIcon = icons.find((i) => i.name === icon.name);
if (foundIcon) {
setMetadata({
name: foundIcon.name,
iconId: `${prefix}:${foundIcon.name}`,
tags: Array.from(foundIcon.categories || new Set()),
collection: info?.name ?? prefix,
license: info?.license?.title ?? 'Unknown License',
});
} else {
setMetadata(null);
}
})
.catch(() => {
setMetadata(null);
});
} else {
setMetadata(null);
}
}, [isOpen, icon, prefix]);
useEffect(() => {
let isMounted = true;
if (isOpen && icon) {
// Fetch metadata from Iconify API with collection info
iconifyApi
.listIcons({ prefix, info: true })
.then(({ icons, info }) => {
if (!isMounted) return;
const foundIcon = icons.find((i) => i.name === icon.name);
if (foundIcon) {
setMetadata({
name: foundIcon.name,
iconId: `${prefix}:${foundIcon.name}`,
tags: Array.from(foundIcon.categories || new Set()),
collection: info?.name ?? prefix,
license: info?.license?.title ?? 'Unknown License',
});
} else {
setMetadata(null);
}
})
.catch(() => {
if (isMounted) setMetadata(null);
});
} else {
setMetadata(null);
}
return () => {
isMounted = false;
};
}, [isOpen, icon, prefix]);

Comment on lines +136 to +142
<div style={{ marginBottom: '16px' }}>
<CopyableText
value={iconName}
label=""
size="lg"
/>
</div>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: Wrap the icon name in a heading element (e.g., <h2>) to satisfy accessibility requirements for dialog titles and match test expectations. The modal currently lacks a programmatically identifiable heading despite the test comment indicating one should exist. [general, importance: 6]

Suggested change
<div style={{ marginBottom: '16px' }}>
<CopyableText
value={iconName}
label=""
size="lg"
/>
</div>
<h2 style={{ marginTop: 0, marginBottom: '16px', fontSize: '1.5rem', fontWeight: 600 }}>
{iconName}
</h2>
<div style={{ marginBottom: '16px' }}>
<CopyableText
value={iconName}
label=""
size="lg"
/>
</div>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant