Skip to content

Conversation

@niranjan-uma-shankar
Copy link

@niranjan-uma-shankar niranjan-uma-shankar commented Aug 10, 2025

Summary

The Unsplash modal is nearly unusable on mobile and tablet devices due to layout issues, small interactive elements, and broken hover/touch behaviours. This PR updates the modal to be fully responsive, fixes interaction bugs on touch devices, and ensures UI elements (such as the Unsplash logo) are visible and accessible. These changes improve the UX in both the lexical editor and the design editor (/settings/design/edit), both of which use the same Unsplash React component.

Issues Fixed:

Fixes all bugs reported in TryGhost/Ghost#24645.

Changes:

  • Responsive grid:
    • Tablet viewports use a 2-column layout.
    • Mobile viewports use a single-column layout for larger, more visible images.
MOBILE TABLET
Screenshot 2025-08-11 at 8 41 13 AM Screenshot 2025-08-11 at 8 40 48 AM

  • Dynamic resizing:
    • Added a resize event listener to adjust columns and behaviours in real time.
    • For ≤540px: hover reveals action buttons, zoom disabled.
    • For 540–1024px: hover reveals action buttons, click-to-zoom enabled.
Screen.Recording.2025-08-11.at.8.48.34.AM.mp4

  • Touch UX improvements:
    • On touch devices, tapping an image reveals action buttons instead of zooming.
      usresize1

  • Zoomed view responsiveness:
    • Properly fits images in zoomed state after resizing.
Screen.Recording.2025-08-11.at.8.55.28.AM.mp4

  • Search bar:

    • Aligned below the title, now spans full width.
  • Unsplash logo:

    • Fixed visibility issue in header.
Screenshot 2025-08-11 at 8 57 17 AM

Testing Instructions

  1. Checkout Koenig repo and run yarn build in packages/kg-unsplash-selector/. Next, follow these steps to link Koenig to your local Ghost admin.
  2. In Ghost admin, open the lexical editor and type /unsplash to open the Unsplash modal.
  3. Verify browser resize and in mobile/tablet simulators whether the modal behaves as described above. Also verify that the overlay buttons like insert and like behave in all viewport dimensions.

The Unsplash modal is not optimised for mobile and tablet viewports and is practically unusable. These fixes ensure that the UX on mobile and tablet views works as one would expect it to.
@coderabbitai
Copy link

coderabbitai bot commented Aug 10, 2025

Walkthrough

Implements responsive column behavior and touch zoom guard in the Unsplash selector flow. Adds viewport-based column count calculation, a window resize listener to update masonry columns and reflow data, and forwards columnCount through UnsplashSearchModal to UnsplashSelector and UnsplashGallery. Introduces UnsplashService.getColumnCount accessor. Layout classes in UnsplashGallery, UnsplashSelector, and UnsplashImage are adjusted based on columnCount and viewport. Adds a mobile/touch early return to disable zoom on selection. Minor className tweaks in UnsplashZoomed.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Zoom behaviour is handled on touch and non-touch devices. This also improves the CSS for better UX on small screens.
@niranjan-uma-shankar niranjan-uma-shankar changed the title Fixes mobile responsiveness Koenig: Improve Unsplash Modal Responsiveness Aug 10, 2025
…en width breakpoints. This ensures that the UI is overall aligned with column count behaviour
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🔭 Outside diff range comments (1)
packages/kg-unsplash-selector/src/ui/UnsplashGallery.tsx (1)

102-109: Forward columnCount to GalleryLayout in all branches

columnCount is only passed in the non-zoomed path. Zoomed and error states default to 0, which changes padding unexpectedly. Forward it for consistency.

Apply this diff:

 // zoomed branch
-<GalleryLayout
-    galleryRef={galleryRef}
-    zoomed={zoomed}>
+<GalleryLayout
+    columnCount={columnCount}
+    galleryRef={galleryRef}
+    zoomed={zoomed}>

 // error branch
-<GalleryLayout
-    galleryRef={galleryRef}
-    zoomed={zoomed}>
+<GalleryLayout
+    columnCount={columnCount}
+    galleryRef={galleryRef}
+    zoomed={zoomed}>
🧹 Nitpick comments (4)
packages/kg-unsplash-selector/src/ui/UnsplashSelector.tsx (1)

26-32: Avoid conflicting padding classes in the header

When columnCount < 3, both px-20 and px-5 are applied; the latter wins but it's noisy. Compose mutually exclusive classes instead.

-<header className={`flex shrink-0 items-center justify-between px-20 py-10 flex-row ${columnCount < 3 ? 'flex-col gap-3 px-5' : ''}`}>
+<header className={`flex shrink-0 items-center justify-between py-10 ${columnCount < 3 ? 'flex-col gap-3 px-5' : 'flex-row px-20'}`}>
packages/kg-unsplash-selector/src/ui/UnsplashImage.tsx (1)

64-68: Alt text: use author’s name for better a11y

The author avatar’s alt="author" is generic. Prefer the actual name.

-<img alt="author" className="mr-2 size-8 shrink-0 rounded-full" src={user.profile_image.medium} />
+<img alt={user.name} className="mr-2 size-8 shrink-0 rounded-full" src={user.profile_image.medium} />
packages/kg-unsplash-selector/src/UnsplashSearchModal.tsx (2)

19-21: Breakpoints: consider centralizing to avoid drift

These width thresholds duplicate similar logic elsewhere (e.g., Tenor selector). Centralize in a shared constants file to keep UX consistent across selectors.


222-235: Pass a stateful columnCount instead of calling the service in JSX

Relying on UnsplashLib.getColumnCount() inside JSX can cause an initial flicker (defaults to 3) before the resize effect runs. Track columnCount in React state and update it in handleResize; pass that down to children.

Minimal changes (snippets show additions outside selected lines too):

  • Add state near other state declarations:
const [columnCount, setColumnCount] = useState<number>(3);
  • Update handleResize:
const newColumnCount = calculateColumnCountForViewport();
if (newColumnCount !== UnsplashLib.getColumnCount()) {
    masonryService.changeColumnCount(newColumnCount);
    setColumnCount(newColumnCount);
    // reflow + setDataset…
}
  • Pass state to children:
-<UnsplashSelector
-    closeModal={onClose}
-    columnCount={UnsplashLib.getColumnCount()}
-    handleSearch={handleSearch}
->
+<UnsplashSelector
+    closeModal={onClose}
+    columnCount={columnCount}
+    handleSearch={handleSearch}
+>-<UnsplashGallery
-    columnCount={UnsplashLib.getColumnCount()}
+<UnsplashGallery
+    columnCount={columnCount}

This keeps header/search/gallery layout in sync without relying on a service read during render.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cefb1b9 and 9154f31.

📒 Files selected for processing (6)
  • packages/kg-unsplash-selector/src/UnsplashSearchModal.tsx (4 hunks)
  • packages/kg-unsplash-selector/src/api/UnsplashService.ts (1 hunks)
  • packages/kg-unsplash-selector/src/ui/UnsplashGallery.tsx (4 hunks)
  • packages/kg-unsplash-selector/src/ui/UnsplashImage.tsx (2 hunks)
  • packages/kg-unsplash-selector/src/ui/UnsplashSelector.tsx (2 hunks)
  • packages/kg-unsplash-selector/src/ui/UnsplashZoomed.tsx (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
packages/kg-unsplash-selector/src/ui/UnsplashGallery.tsx (1)
packages/kg-unsplash-selector/src/UnsplashTypes.ts (1)
  • Photo (49-72)
packages/kg-unsplash-selector/src/UnsplashSearchModal.tsx (2)
packages/koenig-lexical/src/components/ui/TenorSelector.jsx (1)
  • TWO_COLUMN_WIDTH (8-8)
packages/koenig-lexical/src/components/ui/cards/EmbedCard.jsx (1)
  • handleResize (44-80)
🔇 Additional comments (4)
packages/kg-unsplash-selector/src/ui/UnsplashSelector.tsx (1)

31-33: Search container responsiveness — LGTM

Conditional max-w-sm based on columnCount cleanly makes the search bar full-width on narrow layouts.

packages/kg-unsplash-selector/src/ui/UnsplashZoomed.tsx (1)

12-12: Using size-full to fill the zoom container — LGTM

This ensures click-to-close works across the full overlay and keeps the zoomed image centered.

packages/kg-unsplash-selector/src/ui/UnsplashGallery.tsx (1)

83-91: Padding logic — LGTM (matches responsive intent)

Inner scroll area uses px-5 for <3 columns and px-20 otherwise; combined with outer px-5 for single-column this achieves the desired spacing across breakpoints.

packages/kg-unsplash-selector/src/UnsplashSearchModal.tsx (1)

79-90: Viewport-to-columns helper — LGTM

The 1/2/3 column mapping is clear and matches the PR’s behavior spec.

Comment on lines +48 to +50
getColumnCount(): number {
return this.masonryService.columnCount;
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Expose column count in the public interface to keep API consistent

getColumnCount() is public on the class but missing from IUnsplashService. Downstream code typed against the interface won't see it.

Apply this diff to the interface:

 export interface IUnsplashService {
     loadNew(): Promise<void>;
     layoutPhotos(): void;
     getColumns(): Photo[][] | [] | null;
+    getColumnCount(): number;
     updateSearch(term: string): Promise<void>;
     loadNextPage(): Promise<void>;
     clearPhotos(): void;
     triggerDownload(photo: Photo): void;
     photos: Photo[];
     searchIsRunning(): boolean;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
getColumnCount(): number {
return this.masonryService.columnCount;
}
export interface IUnsplashService {
loadNew(): Promise<void>;
layoutPhotos(): void;
getColumns(): Photo[][] | [] | null;
getColumnCount(): number;
updateSearch(term: string): Promise<void>;
loadNextPage(): Promise<void>;
clearPhotos(): void;
triggerDownload(photo: Photo): void;
photos: Photo[];
searchIsRunning(): boolean;
}
🤖 Prompt for AI Agents
In packages/kg-unsplash-selector/src/api/UnsplashService.ts around lines 48 to
50, the class exposes a public getColumnCount(): number method but the
IUnsplashService interface does not declare it; add getColumnCount(): number to
the IUnsplashService interface (exported where it is defined) so downstream code
typed against the interface will see the method and keep the public API
consistent.

return (
<div
className={`relative mb-6 block ${zoomed ? 'h-full w-[max-content] cursor-zoom-out' : 'w-full cursor-zoom-in'}`}
className={`relative mb-6 block ${zoomed ? 'h-full w-[max-content] cursor-zoom-out' : 'w-full [@media(min-width:540px)]:cursor-zoom-in'}`}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve touch UX: make overlay reliably reveal on tap (not just CSS :hover)

Relying on :hover alone is inconsistent on touch. Add focus/active triggers and make the container focusable so a tap reliably reveals controls as per the PR goal.

Within the container div, add tabIndex:

-<div
+<div
+    tabIndex={0}
     className={`relative mb-6 block ${zoomed ? 'h-full w-[max-content] cursor-zoom-out' : 'w-full [@media(min-width:540px)]:cursor-zoom-in'}`}

Then update the overlay to also show on focus/active (outside the selected range, shown for completeness):

-<div className="absolute inset-0 flex flex-col justify-between bg-gradient-to-b from-black/5 via-black/5 to-black/30 p-5 opacity-0 transition-all ease-in-out hover:opacity-100">
+<div className="absolute inset-0 flex flex-col justify-between bg-gradient-to-b from-black/5 via-black/5 to-black/30 p-5 opacity-0 transition-all ease-in-out hover:opacity-100 focus-within:opacity-100 active:opacity-100">

This preserves hover on desktop and enables single-tap reveal on touch.

🤖 Prompt for AI Agents
In packages/kg-unsplash-selector/src/ui/UnsplashImage.tsx around line 35, the
container currently only reveals the overlay via CSS :hover which is unreliable
on touch; make the container focusable by adding a tabIndex (e.g., tabIndex={0})
to the wrapping div and update the overlay's utility classes so it becomes
visible not just on :hover but also on :focus and :active (e.g., add focus: and
active: variants to the same classes that show on hover) so a single tap or
keyboard focus reliably reveals controls while preserving desktop hover
behavior.

Comment on lines +91 to +109
React.useEffect(() => {
const handleResize = () => {
const newColumnCount = calculateColumnCountForViewport();
masonryService.changeColumnCount(newColumnCount);
UnsplashLib.layoutPhotos();
const columns = UnsplashLib.getColumns();
setDataset(columns || []);
};

window.addEventListener('resize', handleResize);

// Call once to make sure state is in sync on mount
handleResize();

return () => {
window.removeEventListener('resize', handleResize);
};
}, [UnsplashLib, masonryService]);

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Guard resize reflow to avoid unnecessary work

On every resize event you reflow masonry and reset dataset even if the column count didn’t change. Add a guard to skip heavy work when the computed count matches the current one.

 const handleResize = () => {
-    const newColumnCount = calculateColumnCountForViewport();
-    masonryService.changeColumnCount(newColumnCount);
-    UnsplashLib.layoutPhotos();
-    const columns = UnsplashLib.getColumns();
-    setDataset(columns || []);
+    const newColumnCount = calculateColumnCountForViewport();
+    // Skip if nothing changes
+    if (newColumnCount === UnsplashLib.getColumnCount()) {
+        return;
+    }
+    masonryService.changeColumnCount(newColumnCount);
+    UnsplashLib.layoutPhotos();
+    const columns = UnsplashLib.getColumns();
+    setDataset(columns || []);
 };

Optional: throttle via requestAnimationFrame or a small debounce to smooth continuous resizing if needed.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/kg-unsplash-selector/src/UnsplashSearchModal.tsx around lines 91 to
109, the resize handler always triggers a full reflow and setDataset even when
the computed column count hasn't changed; update the handler to compute
newColumnCount, compare it to the current column count (use an existing
masonryService getter or track currentColumnCount in a ref/state), and only call
masonryService.changeColumnCount, UnsplashLib.layoutPhotos, and setDataset when
the counts differ; optionally wrap the work in requestAnimationFrame or a small
debounce to avoid repeated heavy work during continuous resize.

Comment on lines 191 to +200
const selectImg = (payload:Photo) => {
const isMobileViewport = window.matchMedia('(max-width: 540px)').matches;
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
const shouldNotZoom = isMobileViewport || isTouchDevice;

if (shouldNotZoom) {
setZoomedImg(null);
return;
}

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Type mismatch: allow null in selectImg signature

This function is called with null from UnsplashZoomed to exit zoom, but it’s typed as Photo. Update to accept Photo | null to match usage and prevent type bugs.

-const selectImg = (payload:Photo) => {
+const selectImg = (payload: Photo | null) => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const selectImg = (payload:Photo) => {
const isMobileViewport = window.matchMedia('(max-width: 540px)').matches;
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
const shouldNotZoom = isMobileViewport || isTouchDevice;
if (shouldNotZoom) {
setZoomedImg(null);
return;
}
const selectImg = (payload: Photo | null) => {
const isMobileViewport = window.matchMedia('(max-width: 540px)').matches;
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
const shouldNotZoom = isMobileViewport || isTouchDevice;
if (shouldNotZoom) {
setZoomedImg(null);
return;
}
// …rest of the handler…
🤖 Prompt for AI Agents
In packages/kg-unsplash-selector/src/UnsplashSearchModal.tsx around lines 191 to
200, the selectImg function is typed to accept Photo but is invoked with null
from UnsplashZoomed to exit zoom; update the function signature to accept Photo
| null and update internal logic to handle a null payload (e.g., early return or
clearing zoomed state) so TypeScript no longer flags a type mismatch; also
update any local usages/call sites and related type annotations (handlers,
props, or state setters) to accept null where appropriate to keep types
consistent.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant