Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a7c1212
chore(release): simplify contribution templates
sebenns Sep 23, 2026
0c8216c
docs(sushi): improve package readme
sebenns Sep 23, 2026
4eb1824
fix(sushi): correct package metadata
sebenns Sep 23, 2026
c870c04
Merge remote-tracking branch 'origin/main' into chore/package-polish
sebenns Sep 23, 2026
470deae
fix(release): upload generated notices file
sebenns Sep 23, 2026
cbc16f8
docs(sushi): clarify Angular 22 support
sebenns Sep 24, 2026
0754bc3
fix(playground): restore document scrolling on mobile
sebenns Sep 27, 2026
b1f363c
fix(sushi): preserve form control sizing on touch focus
sebenns Sep 27, 2026
d963b69
fix(sushi): restore control-specific focus colors
sebenns Sep 27, 2026
3b044c8
fix(sushi): allow breadcrumb items to wrap
sebenns Sep 27, 2026
8d33045
fix(sushi): pin only the first table column
sebenns Sep 27, 2026
7811bd9
fix(sushi): account for visible viewport offsets in selection overlays
sebenns Sep 27, 2026
9badc10
fix(playground): keep form examples within narrow viewports
sebenns Sep 27, 2026
574b879
fix(playground): constrain popover example content
sebenns Sep 27, 2026
696df14
fix(playground): demonstrate custom data view layout controls
sebenns Sep 27, 2026
bf9661a
fix(playground): correct example and code spacing
sebenns Sep 27, 2026
a7e2bcf
test(playground): cover mobile layout and overlay contracts
sebenns Sep 27, 2026
1c62d88
fix(sushi): avoid transient selection colors while pressing options
sebenns Sep 27, 2026
093d5d3
Merge branch 'main' into fix/mobile-layout
sebenns Sep 27, 2026
9b5ab9e
fix(sushi): constrain drawer width to available viewport
sebenns Sep 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
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ npm start

The repository pins its Node.js version in `.nvmrc` and its npm version in `package.json`.

For a phone on the same trusted network, use `npm run start:network` and open the printed `Network` URL.

`sushi/` is the published Angular library. `playground/` contains examples and generated documentation. `tools/` contains repository-only generators and build checks.

## Choose the relevant guide
Expand Down
20 changes: 10 additions & 10 deletions docs/styling-and-themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,16 @@ Use the DaisyUI color roles for the application palette. Every background color

Sushi derives shared interaction and overlay styles from these semantic tokens. Override them when a theme needs a different treatment instead of changing several components:

| Token | Purpose |
| ------------------------ | ----------------------------- |
| `--sui-color-border` | Default component border |
| `--sui-color-muted` | Secondary text and actions |
| `--sui-color-subtle` | Low-emphasis labels |
| `--sui-color-hover` | Neutral hover surface |
| `--sui-color-overlay` | Dialog and popover surface |
| `--sui-focus-ring-color` | Keyboard focus indicator |
| `--sui-shadow-overlay` | Dialog and popover elevation |
| `--sui-disabled-opacity` | Shared disabled-state opacity |
| Token | Purpose |
| ------------------------ | ---------------------------------------------------------- |
| `--sui-color-border` | Default component border |
| `--sui-color-muted` | Secondary text and actions |
| `--sui-color-subtle` | Low-emphasis labels |
| `--sui-color-hover` | Neutral hover surface |
| `--sui-color-overlay` | Dialog and popover surface |
| `--sui-focus-ring-color` | Optional global focus color; otherwise follows the control |
| `--sui-shadow-overlay` | Dialog and popover elevation |
| `--sui-disabled-opacity` | Shared disabled-state opacity |

Component tokens remain the correct choice for a local exception. For example, `--sui-popover-shadow` defaults to `--sui-shadow-overlay` but can be changed on one popover.

Expand Down
5 changes: 5 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export default defineConfig({
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-webkit',
testMatch: ['**/responsive-layout.spec.ts', '**/mobile-overlay.spec.ts'],
use: { ...devices['iPhone 13'] },
},
],
webServer: {
command: 'npm run preview:playground',
Expand Down
43 changes: 27 additions & 16 deletions e2e/specs/drawer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ interface BoxGeometry {
readonly y: number;
}

interface DrawerGeometry {
readonly left: number;
readonly right: number;
readonly width: number;
readonly viewportWidth: number;
readonly documentWidth: number;
}

test('modal Drawer closes with Escape and restores focus to its trigger', async ({ page }: { page: Page }): Promise<void> => {
await page.goto('/drawer');
const trigger: Locator = page.getByRole('button', { name: /Cart/ });
Expand Down Expand Up @@ -80,24 +88,27 @@ test('responsive Drawers use one Sidebar as an overlay and persistent desktop la
await expect(mobileTrigger).toBeFocused();
});

test('small Drawers fill narrow viewports without horizontal overflow', async ({ page }: { page: Page }): Promise<void> => {
test('Drawers stay inside narrow viewports without horizontal overflow', async ({ page }: { page: Page }): Promise<void> => {
await page.setViewportSize({ width: 360, height: 640 });
await page.goto('/drawer');
await page.getByRole('button', { name: /Cart/ }).click();
const panel: Locator = page.getByRole('dialog', { name: 'Your cart' });
await expect
.poll(async (): Promise<boolean> => {
const box: BoxGeometry | null = await panel.boundingBox();
if (box === null) return false;

const viewportWidth: number = await page.evaluate((): number => innerWidth);
const horizontalMargin: number = viewportWidth * 0.02;
const fillsViewport: boolean = box.width >= viewportWidth - horizontalMargin;
const staysContained: boolean = box.x >= -horizontalMargin && box.x + box.width <= viewportWidth + horizontalMargin;
return fillsViewport && staysContained;
})
.toBe(true);
await expect
.poll(async (): Promise<boolean> => page.evaluate((): boolean => document.documentElement.scrollWidth <= innerWidth))
.toBe(true);
await expect(panel).toBeVisible();
await expect(async (): Promise<void> => {
const geometry: DrawerGeometry = await panel.evaluate((element: HTMLElement): DrawerGeometry => {
const bounds: DOMRect = element.getBoundingClientRect();
return {
left: bounds.left,
right: bounds.right,
width: bounds.width,
viewportWidth: document.documentElement.clientWidth,
documentWidth: document.documentElement.scrollWidth,
};
});
const details: string = `Drawer geometry: ${JSON.stringify(geometry)}`;
// Ignore subpixel rounding, not clipping or reserved scrollbar space.
expect(Math.round(geometry.left), details).toBeGreaterThanOrEqual(0);
expect(Math.round(geometry.right), details).toBeLessThanOrEqual(geometry.viewportWidth);
expect(geometry.documentWidth, details).toBeLessThanOrEqual(geometry.viewportWidth);
}).toPass({ timeout: 5_000 });
});
103 changes: 103 additions & 0 deletions e2e/specs/mobile-overlay.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { expect, Locator, Page, test } from '@playwright/test';

interface Bounds {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}

async function scrollContainer(trigger: Locator, distance: number): Promise<void> {
await trigger.evaluate((element: HTMLElement, amount: number): void => {
let parent: HTMLElement | null = element.parentElement;
while (parent) {
const overflow: string = getComputedStyle(parent).overflowY;
if (/(auto|scroll)/.test(overflow) && parent.scrollHeight > parent.clientHeight) {
parent.scrollBy(0, amount);
return;
}
parent = parent.parentElement;
}
window.scrollBy(0, amount);
}, distance);
}

async function expectAnchoredAbove(popup: Locator, trigger: Locator): Promise<void> {
await expect
.poll(
async (): Promise<boolean> => {
const control: Bounds | null = await trigger.boundingBox();
const panel: Bounds | null = await popup.boundingBox();
if (!control || !panel) return false;

const gap: number = control.y - panel.y - panel.height;
return panel.y >= 0 && gap >= 0 && gap < control.height / 2;
},
{ message: 'The popup should stay above and beside its trigger without clipping.' },
)
.toBe(true);
}

test('large Popover stays within a short viewport when opened at the bottom', async ({ page }: { page: Page }): Promise<void> => {
await page.setViewportSize({ width: 390, height: 360 });
await page.goto('/popover');
const trigger: Locator = page.getByRole('button', { name: 'Assign chef', exact: true });
await trigger.evaluate((element: HTMLElement): void => element.scrollIntoView({ block: 'end' }));
await trigger.click();
const popup: Locator = page.getByRole('dialog', { name: 'Select a chef' });
await expect(popup).toBeVisible();
await expect
.poll(async (): Promise<boolean> => {
const panel: Bounds | null = await popup.boundingBox();
return !!panel && panel.y >= 0 && panel.y + panel.height <= 360;
})
.toBe(true);
});

test('Popover stays above its separate target near the bottom edge', async ({ page }: { page: Page }): Promise<void> => {
await page.setViewportSize({ width: 390, height: 740 });
await page.goto('/popover');
const trigger: Locator = page.getByRole('button', { name: 'Show details', exact: true });
const target: Locator = page.getByRole('button', { name: 'Salmon nigiri €12', exact: true });
await target.evaluate((element: HTMLElement): void => element.scrollIntoView({ block: 'end' }));
await trigger.click();
const popup: Locator = page.getByRole('dialog', { name: 'Product details' });
await expect(popup).toBeVisible();
await expectAnchoredAbove(popup, target);
await scrollContainer(target, 80);
await expectAnchoredAbove(popup, target);
});

for (const [route, role, name, popupRole, popupName] of [
['select', 'combobox', 'Favorite color', 'listbox', ''],
['multi-select', 'combobox', 'Message labels', 'listbox', ''],
['popover', 'button', 'Share menu', 'dialog', 'Share this menu'],
['popover', 'button', 'Assign chef', 'dialog', 'Select a chef'],
] as const) {
test(`${name} popup stays anchored above its trigger while scrolling a narrow page`, async ({
page,
isMobile,
}: {
page: Page;
isMobile: boolean;
}): Promise<void> => {
await page.setViewportSize({ width: 390, height: 600 });
await page.goto(`/${route}`);
const trigger: Locator = page.getByRole(role, { name, exact: true });
await trigger.evaluate((element: HTMLElement): void => element.scrollIntoView({ block: 'end' }));
if (isMobile) await trigger.tap();
else await trigger.click();

const popup: Locator = popupName ? page.getByRole(popupRole, { name: popupName }) : page.getByRole(popupRole).first();
await expect(popup).toBeVisible();
await expectAnchoredAbove(popup, trigger);

const initial: Bounds | null = await trigger.boundingBox();
if (!initial) throw new Error('Expected a visible overlay trigger.');
await scrollContainer(trigger, initial.height * 2);
await expect
.poll(async (): Promise<number> => (await trigger.boundingBox())?.y ?? initial.y)
.toBeLessThan(initial.y - initial.height);
await expectAnchoredAbove(popup, trigger);
});
}
Loading
Loading