Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6f9766f
Hide unavailable landscape downloads
cynthia-sg Jul 19, 2026
3eea806
Reduce download dropdown duplication
cynthia-sg Jul 20, 2026
a752d5a
Truncate long mobile member badges
cynthia-sg Jul 20, 2026
44728ac
Add common and embed-item packages to CI linting
cynthia-sg Jul 20, 2026
77d3084
Display tiny language percentages as less than 0.01%
cynthia-sg Jul 20, 2026
f7e9983
Update footer copyright year to 2026
cynthia-sg Jul 20, 2026
e5aa1e5
Handle missing items in the item details modal
cynthia-sg Jul 20, 2026
0739b5e
Add development proxy for landscape data and assets
cynthia-sg Jul 20, 2026
d33da5d
Improve download error handling and retry states
cynthia-sg Jul 20, 2026
dba2c23
Fix modal keyboard and focus accessibility
cynthia-sg Jul 20, 2026
02ac2cc
Handle embedded item loading and failure states
cynthia-sg Jul 20, 2026
89f3c38
Handle frontend data loading failures
cynthia-sg Jul 20, 2026
ecac552
Format item modal source
cynthia-sg Jul 20, 2026
0acca49
Define explicit XXL breakpoint range
cynthia-sg Jul 20, 2026
3f620ba
Reject HTML fallbacks for download requests
cynthia-sg Jul 20, 2026
a6e57f3
Silence Bootstrap sass deprecation warnings
cynthia-sg Jul 24, 2026
1b0e91b
Harden frontend loading and modal states
cynthia-sg Jul 24, 2026
ad3381f
Hide empty landscape download section
cynthia-sg Jul 24, 2026
6b47f0b
Fix stacked modal scroll locking
cynthia-sg Jul 24, 2026
719b4c9
Move UI Development Guide to Webapp README
cynthia-sg Jul 27, 2026
e94666e
Fix Solid type resolution in frontend tests
cynthia-sg Jul 27, 2026
21d4d4a
Merge branch 'main' into cynthia-sg/some-UI-fixes
cynthia-sg Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ jobs:
working-directory: ./ui/webapp
run: yarn test

lint-common:
runs-on:
labels: ubuntu-latest-8-cores
env:
NODE_OPTIONS: --max_old_space_size=4096
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install dependencies
working-directory: ./ui/common
run: yarn install --network-concurrency 1
- name: Run prettier
working-directory: ./ui/common
run: yarn format:diff
- name: Run eslint
working-directory: ./ui/common
run: yarn lint

lint-and-test-embed:
runs-on:
labels: ubuntu-latest-8-cores
Expand All @@ -65,3 +83,21 @@ jobs:
- name: Run eslint
working-directory: ./ui/embed
run: yarn lint

lint-embed-item:
runs-on:
labels: ubuntu-latest-8-cores
env:
NODE_OPTIONS: --max_old_space_size=4096
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install dependencies
working-directory: ./ui/embed-item
run: yarn install --network-concurrency 1
- name: Run prettier
working-directory: ./ui/embed-item
run: yarn format:diff
- name: Run eslint
working-directory: ./ui/embed-item
run: yarn lint
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ ui/embed/static/*
ui/embed-item/dist
ui/embed-item/node_modules
ui/webapp/dist
ui/webapp/.env.local
ui/webapp/node_modules
ui/webapp/static/*
yarn-debug.log*
Expand Down
2 changes: 1 addition & 1 deletion docs/config/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ footer:
youtube: "https://www.youtube.com/c/cloudnativefdn"
logo: "https://raw.githubusercontent.com/cncf/artwork/master/other/cncf/horizontal/white/cncf-white.svg"
text: |
Copyright © 2024 The Linux Foundation®. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our [Trademark Usage page](https://www.linuxfoundation.org/trademark-usage). Linux is a registered trademark of Linus Torvalds. [Privacy Policy](https://www.linuxfoundation.org/privacy) and [Terms of Use](https://www.linuxfoundation.org/terms). This website contains data received from Crunchbase. This data is not licensed pursuant to the Apache License. It is subject to Crunchbase's Data Access Terms, available at <https://data.crunchbase.com/docs/terms>, and is only permitted to be used with Linux Foundation landscape projects.
Copyright © 2026 The Linux Foundation®. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our [Trademark Usage page](https://www.linuxfoundation.org/trademark-usage). Linux is a registered trademark of Linus Torvalds. [Privacy Policy](https://www.linuxfoundation.org/privacy) and [Terms of Use](https://www.linuxfoundation.org/terms). This website contains data received from Crunchbase. This data is not licensed pursuant to the Apache License. It is subject to Crunchbase's Data Access Terms, available at <https://data.crunchbase.com/docs/terms>, and is only permitted to be used with Linux Foundation landscape projects.

# Grid items size (optional)
#
Expand Down
2 changes: 1 addition & 1 deletion ui/common/src/components/ItemModalMobileContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export const ItemModalMobileContent = (props: Props) => {
<Show when={!isUndefined(itemInfo()!.member_subcategory)}>
<div
title={`${itemInfo()!.member_subcategory} member`}
class={`badge rounded-0 text-uppercase me-2 ${BadgeOutlineDark} badgeOutlineDark`}
class={`badge rounded-0 text-truncate text-uppercase me-2 ${BadgeOutlineDark} badgeOutlineDark`}
>
{itemInfo()!.member_subcategory} member
</div>
Expand Down
3 changes: 2 additions & 1 deletion ui/common/src/components/LanguagesStats.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createEffect, createSignal, For, on } from 'solid-js';

import { formatPercentage } from '../utils/formatPercentage';
import { sortObjectByValue } from '../utils/sortObjectByValue';
import { Box } from './Box';

Expand Down Expand Up @@ -36,7 +37,7 @@ export const LanguagesStats = (props: Props) => {
return (
<Box
class={props.boxClass}
value={`${Number.isInteger(value()) ? value() : value().toFixed(2)}%`}
value={formatPercentage(value())}
legend={lang}
fillBgPercentage={value()}
description={`${lang} language percentage`}
Expand Down
15 changes: 7 additions & 8 deletions ui/common/src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createEffect, createSignal, JSXElement, Show } from 'solid-js';
import { css, keyframes } from 'solid-styled-components';

import { BANNER_ID } from '../data/data';
import { useBodyScroll, useOutsideClick } from '../hooks';
import { useBodyScroll, useModalFocus, useOutsideClick } from '../hooks';
import { SVGIconKind } from '../types/types';
import { SVGIcon } from './SVGIcon';

Expand Down Expand Up @@ -117,19 +117,18 @@ const Btn = css`

export const Modal = (props: Props) => {
const [openStatus, setOpenStatus] = createSignal(false);
const [modalRef, setModalRef] = createSignal<HTMLDivElement>();
const [ref, setRef] = createSignal<HTMLDivElement>();

useOutsideClick([ref], [BANNER_ID], openStatus, () => {
closeModal();
});

useBodyScroll(openStatus, 'modal');

const closeModal = () => {
setOpenStatus(false);
props.onClose();
};

useBodyScroll(openStatus, 'modal');
useModalFocus(modalRef, openStatus, closeModal);
useOutsideClick([ref], [BANNER_ID], openStatus, closeModal);

createEffect(() => {
setOpenStatus(props.open);
});
Expand All @@ -141,12 +140,12 @@ export const Modal = (props: Props) => {
</Show>

<div
ref={setModalRef}
class={`modal d-block ${ModalClass} ${Active}`}
role="dialog"
tabIndex={-1}
aria-label={`${props.title} modal`}
aria-modal={true}
aria-hidden={true}
>
<div
ref={setRef}
Expand Down
72 changes: 72 additions & 0 deletions ui/common/src/hooks/__tests__/useBodyScroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createRoot, createSignal } from 'solid-js';

import { useBodyScroll } from '../useBodyScroll';

describe('useBodyScroll', () => {
let disposeTestRoot: (() => void) | undefined;

afterEach(() => {
disposeTestRoot?.();
document.body.removeAttribute('class');
});

it('should keep scrolling locked when another modal instance is closed', async () => {
const [activeModalOpen, setActiveModalOpen] = createSignal(true);
const [inactiveModalOpen] = createSignal(false);

disposeTestRoot = createRoot((disposeRoot) => {
useBodyScroll(activeModalOpen, 'modal');
useBodyScroll(inactiveModalOpen, 'modal');
return disposeRoot;
});
await Promise.resolve();

expect(document.body.classList.contains('noScroll-modal')).toBe(true);

setActiveModalOpen(false);
await Promise.resolve();

expect(document.body.classList.contains('noScroll-modal')).toBe(false);
});

it('should keep scrolling locked until the last stacked modal closes', async () => {
const [childModalOpen, setChildModalOpen] = createSignal(true);
const [parentModalOpen, setParentModalOpen] = createSignal(true);

disposeTestRoot = createRoot((disposeRoot) => {
useBodyScroll(parentModalOpen, 'modal');
useBodyScroll(childModalOpen, 'modal');
return disposeRoot;
});
await Promise.resolve();

expect(document.body.classList.contains('noScroll-modal')).toBe(true);

setChildModalOpen(false);
await Promise.resolve();

expect(document.body.classList.contains('noScroll-modal')).toBe(true);

setParentModalOpen(false);
await Promise.resolve();

expect(document.body.classList.contains('noScroll-modal')).toBe(false);
});

it('should release an active scroll lock during cleanup', async () => {
const [modalOpen] = createSignal(true);

disposeTestRoot = createRoot((disposeRoot) => {
useBodyScroll(modalOpen, 'modal');
return disposeRoot;
});
await Promise.resolve();

expect(document.body.classList.contains('noScroll-modal')).toBe(true);

disposeTestRoot();
disposeTestRoot = undefined;

expect(document.body.classList.contains('noScroll-modal')).toBe(false);
});
});
126 changes: 126 additions & 0 deletions ui/common/src/hooks/__tests__/useModalFocus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { createRoot, createSignal } from 'solid-js';

import { useModalFocus } from '../useModalFocus';

describe('useModalFocus', () => {
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const originalRequestAnimationFrame = window.requestAnimationFrame;
let animationFrameCallbacks: Map<number, FrameRequestCallback>;
let disposeTestRoot: (() => void) | undefined;
let nextAnimationFrameId: number;

beforeEach(() => {
animationFrameCallbacks = new Map();
disposeTestRoot = undefined;
nextAnimationFrameId = 0;
window.cancelAnimationFrame = jest.fn((animationFrameId: number) => {
animationFrameCallbacks.delete(animationFrameId);
});
window.requestAnimationFrame = jest.fn((callback: FrameRequestCallback) => {
nextAnimationFrameId += 1;
animationFrameCallbacks.set(nextAnimationFrameId, callback);
return nextAnimationFrameId;
});
});

afterEach(() => {
disposeTestRoot?.();
window.cancelAnimationFrame = originalCancelAnimationFrame;
window.requestAnimationFrame = originalRequestAnimationFrame;
document.body.replaceChildren();
});

it('should close only the nested modal and restore focus to its trigger', async () => {
const parentModal = document.createElement('div');
const childModal = document.createElement('div');
const childCloseButton = document.createElement('button');
const childModalTrigger = document.createElement('button');
const onChildClose = jest.fn();
const onParentClose = jest.fn();

markVisible(childCloseButton);
markVisible(childModalTrigger);
childModal.appendChild(childCloseButton);
parentModal.append(childModalTrigger, childModal);
document.body.appendChild(parentModal);
childModalTrigger.focus();

disposeTestRoot = createRoot((disposeRoot) => {
const [childOpen, setChildOpen] = createSignal(true);
const [parentOpen, setParentOpen] = createSignal(true);

useModalFocus(
() => parentModal,
parentOpen,
() => {
onParentClose();
setParentOpen(false);
}
);
useModalFocus(
() => childModal,
childOpen,
() => {
onChildClose();
setChildOpen(false);
}
);

return disposeRoot;
});

await Promise.resolve();
runAnimationFrames(animationFrameCallbacks);
expect(document.activeElement).toBe(childCloseButton);

childCloseButton.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(onChildClose).toHaveBeenCalledTimes(1);
expect(onParentClose).not.toHaveBeenCalled();
expect(document.activeElement).toBe(childModalTrigger);

childModal.remove();
});

it('should wrap focus without including hidden controls', async () => {
const modal = document.createElement('div');
const firstButton = document.createElement('button');
const hiddenButton = document.createElement('button');
const lastButton = document.createElement('button');

hiddenButton.style.display = 'none';
markVisible(firstButton);
markVisible(lastButton);
modal.append(firstButton, lastButton, hiddenButton);
document.body.appendChild(modal);

disposeTestRoot = createRoot((disposeRoot) => {
const [open] = createSignal(true);
useModalFocus(() => modal, open, jest.fn());
return disposeRoot;
});

await Promise.resolve();
runAnimationFrames(animationFrameCallbacks);
expect(document.activeElement).toBe(firstButton);

lastButton.focus();
lastButton.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Tab' }));
expect(document.activeElement).toBe(firstButton);

firstButton.focus();
firstButton.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Tab', shiftKey: true }));
expect(document.activeElement).toBe(lastButton);
});
});

const runAnimationFrames = (animationFrameCallbacks: Map<number, FrameRequestCallback>) => {
const callbacks = Array.from(animationFrameCallbacks.values());
animationFrameCallbacks.clear();
callbacks.forEach((callback) => callback(performance.now()));
};

const markVisible = (element: HTMLElement) => {
jest.spyOn(element, 'getClientRects').mockReturnValue([{} as DOMRect] as unknown as DOMRectList);
};
1 change: 1 addition & 0 deletions ui/common/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { useBodyScroll } from './useBodyScroll';
export { useBreakpointDetect } from './useBreakpointDetect';
export { useModalFocus } from './useModalFocus';
export { useOutsideClick } from './useOutsideClick';
24 changes: 22 additions & 2 deletions ui/common/src/hooks/useBodyScroll.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
import { Accessor, createEffect, onCleanup } from 'solid-js';

const bodyScrollLocks = new Map<string, Set<symbol>>();

/** Keeps body scrolling locked while the provided accessor is blocked. */
export const useBodyScroll = (blocked: Accessor<boolean>, elType: string) => {
const className = `noScroll-${elType}`;
const lockId = Symbol(className);

createEffect(() => {
if (blocked()) {
const locks = bodyScrollLocks.get(className) || new Set<symbol>();
locks.add(lockId);
bodyScrollLocks.set(className, locks);
document.body.classList.add(className);
} else {
document.body.classList.remove(className);
releaseBodyScrollLock(className, lockId);
}
});

onCleanup(() => {
document.body.classList.remove(className);
releaseBodyScrollLock(className, lockId);
});
};

const releaseBodyScrollLock = (className: string, lockId: symbol) => {
const locks = bodyScrollLocks.get(className);
if (!locks) {
return;
}

locks.delete(lockId);
if (locks.size === 0) {
bodyScrollLocks.delete(className);
document.body.classList.remove(className);
}
};
Loading