Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 .github/workflows/push-tagged-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ jobs:
run: yarn install
- name: Create dist JS
run: yarn build:all
- name: Check package consumers
run: yarn test:package:consumer
- name: Create jest results
# only use for final releases because it is necessary only in addition to storybook
if: ${{ inputs.onlyNpmPush != true && inputs.sectionChangelog != 'Unreleased' }}
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/test-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ on:
pull_request:
paths:
- ".github/workflows/test-code.yml"
- ".typescript/**"
- "scripts/**"
- "tests/package-consumer/**"
- "src/**.js"
- "src/**.ts"
- "src/**.tsx"
- "index.ts"
- "**.scss"
- package.json
- tsconfig.json
- yarn.lock

jobs:
Expand All @@ -24,4 +28,5 @@ jobs:
- run: yarn install
- run: yarn compile
- run: yarn compile-scss
- run: yarn test:package
- run: yarn test:ci
33 changes: 33 additions & 0 deletions .github/workflows/test-storybook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: "Test: Storybook browser accessibility"

on:
pull_request:
paths:
- ".github/workflows/test-storybook.yml"
- ".storybook/**"
- ".typescript/**"
- "src/**"
- "scripts/**"
- "package.json"
- "yarn.lock"

jobs:
browser-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24.11.1"
- run: yarn install --frozen-lockfile
- run: yarn test:storybook:types
- run: yarn test:storybook:runner
- run: yarn playwright install --with-deps chromium
- run: yarn build-storybook --output-dir .local/storybook
- run: yarn test:storybook:ci --json --outputFile .local/storybook-results.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: storybook-browser-results
path: .local/storybook-results.json
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@ storybook-static

# ignore Typescript assets
*.tsbuildinfo
.local/
181 changes: 181 additions & 0 deletions .storybook/tests/file-upload.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Buffer } from "node:buffer";
import type { Locator, Page } from "playwright";
import { expect } from "playwright/test";

interface FileUploadScenario {
storyId: `forms-fileupload--${string}`;
description: string;
run: (page: Page) => Promise<void>;
}

function uploadGroup(page: Page): Locator {
return page.getByRole("group", { name: "Upload graph file", exact: true });
}

function browseButton(page: Page): Locator {
return uploadGroup(page).getByRole("button", { name: /browse files$/i });
}

function fileRow(page: Page, fileName: string): Locator {
return uploadGroup(page)
.getByRole("listitem")
.filter({ has: page.getByText(fileName, { exact: true }) });
}

async function testKeyboardSelection(page: Page, key: "Enter" | "Space"): Promise<void> {
// Begin outside the controls so Tab, rather than programmatic focus, reaches Browse.
await uploadGroup(page).focus();
await page.keyboard.press("Tab");
await expect(browseButton(page)).toBeFocused();

let chooserCount = 0;
const countChooser = () => {
chooserCount += 1;
};
page.on("filechooser", countChooser);
try {
const chooserPromise = page.waitForEvent("filechooser");
await page.keyboard.press(key);
const chooser = await chooserPromise;
await chooser.setFiles({ name: "keyboard.ttl", mimeType: "text/turtle", buffer: Buffer.from("data") });

await expect(fileRow(page, "keyboard.ttl")).toBeVisible();
expect(chooserCount, "Each key activation must open exactly one chooser").toBe(1);
await expect(page.getByRole("status")).toContainText("Selected keyboard.ttl");
} finally {
page.off("filechooser", countChooser);
}
}

async function testDisabledSelection(page: Page): Promise<void> {
const browse = browseButton(page);
await expect(browse).toBeDisabled();

await uploadGroup(page).focus();
await page.keyboard.press("Tab");

await expect(browse).not.toBeFocused();
}

async function testKeyboardCancellation(page: Page): Promise<void> {
const group = uploadGroup(page);
await expect(fileRow(page, "graph.ttl")).toHaveAttribute("data-state", "uploading");
await expect(group).toHaveAttribute("aria-busy", "true");

await group.getByRole("button", { name: "Cancel upload", exact: true }).focus();
await page.keyboard.press("Space");

await expect(fileRow(page, "graph.ttl")).toHaveAttribute("data-state", "cancelled");
await expect(group).not.toHaveAttribute("aria-busy");
await expect(group.getByRole("listitem"), "Cancellation retains the file").toHaveCount(1);
await expect(group.getByRole("progressbar")).toHaveAttribute("aria-valuetext", "Upload cancelled");
await expect(group.getByText("Upload cancelled", { exact: true })).toBeVisible();
}

async function waitForCancelledFiles(page: Page, fileNames: readonly string[]): Promise<void> {
// CancelledFiles.play completes the first file and stops the remaining uploads.
await expect(fileRow(page, "first.ttl")).toHaveAttribute("data-state", "complete");
for (const fileName of fileNames) {
await expect(fileRow(page, fileName)).toHaveAttribute("data-state", "cancelled");
}
await expect(uploadGroup(page)).not.toHaveAttribute("aria-busy");
}

async function testFocusAfterRemoval(page: Page): Promise<void> {
await waitForCancelledFiles(page, ["second.ttl", "third.ttl"]);
const removeSecond = fileRow(page, "second.ttl").getByRole("button", { name: "Remove", exact: true });
const removeThird = fileRow(page, "third.ttl").getByRole("button", { name: "Remove", exact: true });

await removeSecond.focus();
await page.keyboard.press("Enter");

await expect(fileRow(page, "second.ttl")).toHaveCount(0);
await expect(removeThird).toBeFocused();
await expect(uploadGroup(page).getByRole("button", { name: "Remove", exact: true })).toHaveCount(1);
await expect(page.getByRole("status")).toContainText("second.ttl removed");

await page.keyboard.press("Space");

await expect(fileRow(page, "third.ttl")).toHaveCount(0);
await expect(browseButton(page)).toBeFocused();
await expect(page.getByRole("status")).toContainText("third.ttl removed");
}

async function testContinueRetainedFiles(page: Page): Promise<void> {
await waitForCancelledFiles(page, ["second.ttl"]);
// RemovedBeforeContinue.play also removes the third file before this interaction.
await expect(fileRow(page, "third.ttl")).toHaveCount(0);
await expect(
uploadGroup(page).getByRole("progressbar", { name: "Overall upload progress", exact: true }),
).toHaveAttribute("aria-valuenow", "50");

await uploadGroup(page).getByRole("button", { name: "Continue uploads", exact: true }).focus();
await page.keyboard.press("Enter");

await expect(fileRow(page, "second.ttl")).toHaveAttribute("data-state", "uploading");
await expect(fileRow(page, "third.ttl")).toHaveCount(0);
await expect(uploadGroup(page).getByRole("listitem")).toHaveCount(2);
await expect(uploadGroup(page)).toHaveAttribute("aria-busy", "true");
}

async function testRetryAfterError(page: Page): Promise<void> {
const row = fileRow(page, "retry.ttl");
await expect(row).toHaveAttribute("data-state", "error");
await expect(page.getByRole("alert")).toBeVisible();

await row.getByRole("button", { name: "Retry", exact: true }).focus();
await page.keyboard.press("Enter");

await expect(row).toHaveAttribute("data-state", "complete");
await expect(page.getByRole("alert")).toHaveCount(0);
await expect(page.getByRole("status")).toContainText("retry.ttl uploaded");
await expect(uploadGroup(page)).not.toHaveAttribute("aria-busy");
}

export const fileUploadScenarios = [
{
storyId: "forms-fileupload--keyboard-enter",
description: "Enter opens one file chooser and announces the selection",
run: (page) => testKeyboardSelection(page, "Enter"),
},
{
storyId: "forms-fileupload--keyboard-space",
description: "Space opens one file chooser and announces the selection",
run: (page) => testKeyboardSelection(page, "Space"),
},
{
storyId: "forms-fileupload--keyboard-in-dialog",
description: "Enter opens one file chooser inside a dialog",
run: (page) => testKeyboardSelection(page, "Enter"),
},
{
storyId: "forms-fileupload--disabled",
description: "A disabled upload control skips Browse in the tab order",
run: testDisabledSelection,
},
{
storyId: "forms-fileupload--selection-disabled",
description: "Disabled file selection skips Browse in the tab order",
run: testDisabledSelection,
},
{
storyId: "forms-fileupload--uploading",
description: "Space cancels an upload and retains its file",
run: testKeyboardCancellation,
},
{
storyId: "forms-fileupload--keyboard-removal",
description: "Removing files moves focus to the next Remove button, then Browse",
run: testFocusAfterRemoval,
},
{
storyId: "forms-fileupload--removed-before-continue",
description: "Continue resumes retained files without restoring a removed file",
run: testContinueRetainedFiles,
},
{
storyId: "forms-fileupload--retry-after-error",
description: "Enter retries a failed upload and announces success",
run: testRetryAfterError,
},
] satisfies readonly FileUploadScenario[];
16 changes: 16 additions & 0 deletions .storybook/tests/runner.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import process from "node:process";
import { fileURLToPath } from "node:url";

const runner = fileURLToPath(new URL("../../scripts/run-storybook-tests.mts", import.meta.url));

const invalidArgs = [["--url"], ["--url", ""], ["--url", "--ci"]];
for (const args of invalidArgs) {
const result = spawnSync(process.execPath, [runner, ...args], { encoding: "utf8", timeout: 10_000 });

assert.ifError(result.error);
assert.equal(result.status, 1, `Expected rejection of ${JSON.stringify(args)}`);
assert.match(result.stderr, /--url requires a URL value/);
}
process.stdout.write(`Passed ${invalidArgs.length} Storybook runner argument checks.\n`);
3 changes: 2 additions & 1 deletion .typescript/tsbuild-cjs.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"extends": "./../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"module": "node20",
"moduleResolution": "node16",
"target": "es2015",
"noEmit": false,
"outDir": "../dist/cjs",
Expand Down
4 changes: 4 additions & 0 deletions .typescript/tscheck-fileupload.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.json",
"include": ["../scripts/type-tests/file-upload.tsx", "../declarations.d.ts"]
}
17 changes: 17 additions & 0 deletions .typescript/tscheck-package-consumer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["../scripts/test-package-consumer.mts"]
}
18 changes: 18 additions & 0 deletions .typescript/tscheck-storybook.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["../.storybook/tests/**/*.mts", "../scripts/run-storybook-tests.mts", "../scripts/test-storybook.mts"]
}
22 changes: 19 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Added

- `<Button />` forwards refs to its underlying button or anchor element
- Package smoke tests that render `<FileUpload />` from the built ESM and CommonJS root exports
- Reusable Storybook browser-test commands and CI, with FileUpload accessibility scans and native keyboard/focus coverage
- `<FileUpload />`: a reusable, accessible component for selecting and uploading files, with built-in queue management, progress and error handling
- browse or drag and drop single or multiple files, with configurable file type, size and count limits
- upload automatically or on demand, sequentially or with configurable parallel requests
- approve files synchronously or asynchronously, for example through an overwrite confirmation dialog
- show per-file and overall progress, with cancel, retry, remove and stop/continue actions; cancelled files remain in overall progress until removed
- integrate with application endpoints and headers, typed response parsing, state updates and completion callbacks
- provide translated labels and errors, including structured restriction details, with keyboard and screen-reader support
- [consumer guide](src/components/FileUpload/README.md) with usage examples and lifecycle guidance
- `<Switch />`
- `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true`
- `<Markdown />`
Expand All @@ -34,10 +45,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Changed

- Resolve Uppy 5 through its public package types and remove the legacy Uppy resolution bridge
- Upgrading base libraries
- Carbon, Codemirror, React-Flow
- minimum node version (`engines.node`) is `18.19.0` now
- the build of the ESM distribution needs a synchronous `import.meta.resolve`, which is only available since this version
- Carbon, Codemirror, React-Flow, Uppy
- Minimum Node.js version (`engines.node`) is `24.11.1` now, matching the frontend build image,
`.nvmrc` and CI
- `<FieldItem />`
- the used `Label` element gets the `eccgui-fielditem__label` class now
- `<StringPreviewContentBlobToggler />`
Expand All @@ -48,6 +60,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Fixed

- `<FileUpload />`: improve text contrast in drag-over and cancelled-file states
- ESM distribution
- CommonJS `he` and `lodash` are consumed through their interoperable default exports
- `TruncateMarkdownDisplayType` is exported so the declaration build can name the public `utils` type
- `<PropertyValuePair />`
- fix description and story to point out that `PropertyValueList` need always to be used as wrapper
- `<ApplicationViewability />`
Expand Down
Loading
Loading