Skip to content

devex: Data grid component #2561

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 36 commits into from
May 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
dd39b27
add data grid
SuaYoo Apr 22, 2025
ba6d06c
wip input
SuaYoo Apr 23, 2025
31e2eb4
filter out private and static members
SuaYoo Apr 23, 2025
b399e9f
wip edit cells
SuaYoo Apr 23, 2025
7061126
remove unused plugin
SuaYoo Apr 24, 2025
afd6114
handle validation
SuaYoo Apr 24, 2025
157cd16
wip form control
SuaYoo Apr 25, 2025
d5d91c7
wip validation
SuaYoo Apr 28, 2025
57eff26
create cell component
SuaYoo Apr 29, 2025
6e7312d
add form helper
SuaYoo Apr 29, 2025
fd83c81
show tooltip on blur
SuaYoo Apr 29, 2025
2f4a070
add basic keyboard nav
SuaYoo Apr 29, 2025
e2722d6
wip accessibility
SuaYoo Apr 29, 2025
f8ddac6
focus accessibility
SuaYoo Apr 29, 2025
c3d9641
rename decorator
SuaYoo Apr 29, 2025
bbd8e3b
rename decorator
SuaYoo Apr 29, 2025
94a804b
migrate usage history table
SuaYoo Apr 29, 2025
38778f4
support separate add and remove rows
SuaYoo Apr 29, 2025
dacc391
support row key
SuaYoo Apr 29, 2025
894975d
add deprecation notice
SuaYoo Apr 29, 2025
e139f4d
update comments
SuaYoo Apr 29, 2025
e51cf81
overflow in small screens
SuaYoo Apr 29, 2025
28c7b51
add comment
SuaYoo Apr 29, 2025
6ba2cf3
Apply suggestions from code review
SuaYoo Apr 30, 2025
9735d9c
link to refactor issue in comments
SuaYoo Apr 30, 2025
e942aff
use map for id
SuaYoo Apr 30, 2025
c21f915
generate id for label
SuaYoo Apr 30, 2025
c3d4246
enable adding more than one row
SuaYoo Apr 30, 2025
8d23a45
Update frontend/src/components/ui/data-grid/data-grid.ts
SuaYoo Apr 30, 2025
55584d3
fix url input validity check
SuaYoo Apr 30, 2025
576622e
revert id caching
SuaYoo Apr 30, 2025
a417260
clean up table
SuaYoo Apr 30, 2025
a2936ed
fix sticky header
SuaYoo May 7, 2025
b55b952
rename data grid decorator
SuaYoo May 7, 2025
6afb317
fix lint issue
SuaYoo May 7, 2025
f90f25c
devex: Refactor `syntax-input` and `link-selector-table` into form co…
SuaYoo May 8, 2025
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
33 changes: 31 additions & 2 deletions frontend/custom-elements-manifest.config.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export default {
/** Globs to analyze */
globs: ["src/**/*.ts"],
globs: ["src/components/**/*.ts", "src/features/**/*.ts"],
/** Globs to exclude */
exclude: ["__generated__", "__mocks__"],
exclude: ["src/**/*.stories.ts"],
/** Directory to output CEM to */
outdir: "src/__generated__",
/** Run in dev mode, provides extra logging */
Expand All @@ -15,4 +15,33 @@ export default {
packagejson: false,
/** Enable special handling for litelement */
litelement: true,
/** Provide custom plugins */
plugins: [filterPrivateFields()],
};

// Filter private fields
// Based on https://github.com/storybookjs/storybook/issues/15436#issuecomment-1856333227
function filterPrivateFields() {
return {
name: "web-components-private-fields-filter",
analyzePhase({ ts, node, moduleDoc }) {
switch (node.kind) {
case ts.SyntaxKind.ClassDeclaration: {
const className = node.name.getText();
const classDoc = moduleDoc?.declarations?.find(
(declaration) => declaration.name === className,
);

if (classDoc?.members) {
// Filter both private and static members
// TODO May be able to avoid some of this with `#` private member prefix
// https://github.com/webrecorder/browsertrix/issues/2563
classDoc.members = classDoc.members.filter(
(member) => !member.privacy && !member.static,
);
}
}
}
},
};
}
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"replaywebpage": "^2.2.4",
"slugify": "^1.6.6",
"style-loader": "^3.3.0",
"tabbable": "^6.2.0",
"tailwindcss": "^3.4.1",
"terser-webpack-plugin": "^5.3.10",
"thread-loader": "^4.0.4",
Expand Down
156 changes: 156 additions & 0 deletions frontend/src/__mocks__/api/orgs/[id].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// API v1.15.0
export default {
id: "x_example_org_id_x",
name: "Example Org",
slug: "example-org",
users: {
"[email protected]": {
role: 40,
name: "Alice",
email: "[email protected]",
},
"[email protected]": {
role: 20,
name: "Bob",
email: "[email protected]",
},
"[email protected]": {
role: 20,
name: "Carol",
email: "[email protected]",
},
"[email protected]": {
role: 10,
name: "Dave",
email: "[email protected]",
},
"[email protected]": {
role: 10,
name: "Eve",
email: "[email protected]",
},
},
created: "2023-11-08T17:19:23Z",
default: false,
bytesStored: 22143878904,
bytesStoredCrawls: 22023942070,
bytesStoredUploads: 38872471,
bytesStoredProfiles: 81064363,
origin: null,
storageQuotaReached: false,
execMinutesQuotaReached: false,
usage: {
"2023-11": 473,
"2023-12": 1273,
"2024-01": 4752,
"2024-03": 26,
"2024-04": 398,
"2024-05": 3030,
"2024-06": 2628,
"2024-07": 1655,
"2024-08": 1289,
"2024-10": 308,
"2025-04": 5723,
},
crawlExecSeconds: {
"2023-11": 760,
"2023-12": 1771,
"2024-01": 4939,
"2024-03": 14,
"2024-04": 253,
"2024-05": 2958,
"2024-06": 4276,
"2024-07": 2066,
"2024-08": 2250,
"2024-10": 233,
"2025-01": 1,
"2025-04": 5688,
},
qaUsage: {
"2024-04": 214,
"2024-05": 1526,
"2024-06": 894,
"2024-08": 1188,
"2024-10": 314,
"2025-01": 166,
},
qaCrawlExecSeconds: {
"2024-04": 174,
"2024-05": 1484,
"2024-06": 1914,
"2024-08": 3131,
"2024-10": 943,
"2025-01": 366,
},
monthlyExecSeconds: { "2023-11": 760, "2023-12": 1771, "2024-01": 3000 },
extraExecSeconds: {},
giftedExecSeconds: {},
extraExecSecondsAvailable: 0,
giftedExecSecondsAvailable: 0,
quotas: {
storageQuota: 100000000000,
maxExecMinutesPerMonth: 0,
maxConcurrentCrawls: 10,
maxPagesPerCrawl: 0,
extraExecMinutes: 0,
giftedExecMinutes: 0,
},
quotaUpdates: [
{
modified: "2024-01-18T07:16:22.534000Z",
update: {
storageQuota: 1000000000000,
maxExecMinutesPerMonth: 0,
maxConcurrentCrawls: 10,
maxPagesPerCrawl: 0,
extraExecMinutes: 0,
giftedExecMinutes: 0,
},
},
{
modified: "2024-07-17T15:36:45Z",
update: {
storageQuota: 10000000000,
maxExecMinutesPerMonth: 0,
maxConcurrentCrawls: 10,
maxPagesPerCrawl: 0,
extraExecMinutes: 0,
giftedExecMinutes: 0,
},
},
{
modified: "2024-07-17T15:39:26Z",
update: {
storageQuota: 100000000000,
maxExecMinutesPerMonth: 0,
maxConcurrentCrawls: 10,
maxPagesPerCrawl: 0,
extraExecMinutes: 0,
giftedExecMinutes: 0,
},
},
],
webhookUrls: {
crawlStarted: null,
crawlFinished: null,
crawlDeleted: null,
qaAnalysisStarted: null,
qaAnalysisFinished: null,
crawlReviewed: null,
uploadFinished: null,
uploadDeleted: null,
addedToCollection: null,
removedFromCollection: null,
collectionDeleted: null,
},
readOnly: false,
readOnlyReason: "",
subscription: null,
allowSharedProxies: false,
allowedProxies: ["nz-proxy-1"],
crawlingDefaults: null,
lastCrawlFinished: "2025-04-02T23:00:50Z",
enablePublicProfile: false,
publicDescription: "This is an example org.",
publicUrl: "https://example.com",
};
2 changes: 1 addition & 1 deletion frontend/src/components/ui/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { html as staticHtml, unsafeStatic } from "lit/static-html.js";
import { TailwindElement } from "@/classes/TailwindElement";
import { tw } from "@/utils/tailwind";

enum Language {
export enum Language {
Javascript = "javascript",
XML = "xml",
CSS = "css",
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/ui/config-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,10 @@ export class ConfigDetails extends BtrixElement {
selectors.length
? html`
<div class="mb-2">
<btrix-link-selector-table .selectors=${selectors}>
<btrix-link-selector-table
.selectors=${selectors}
aria-readonly="true"
>
</btrix-link-selector-table>
</div>
`
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/components/ui/data-grid/cellDirective.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Directive, type PartInfo } from "lit/directive.js";

import type { DataGridCell } from "./data-grid-cell";
import type { GridColumn } from "./types";

/**
* Directive for replacing `renderCell` and `renderEditCell`
* methods with custom render functions.
*/
export class CellDirective extends Directive {
private readonly element?: DataGridCell;

constructor(partInfo: PartInfo & { element?: DataGridCell }) {
super(partInfo);
this.element = partInfo.element;
}

render(col: GridColumn) {
if (!this.element) return;

if (col.renderCell) {
this.element.renderCell = col.renderCell;
}

if (col.renderEditCell) {
this.element.renderEditCell = col.renderEditCell;
}
}
}
135 changes: 135 additions & 0 deletions frontend/src/components/ui/data-grid/controllers/focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { ReactiveController } from "lit";
import {
focusable,
isFocusable,
isTabbable,
tabbable,
type FocusableElement,
} from "tabbable";

import type { DataGridCell } from "../data-grid-cell";
import type { DataGridRow } from "../data-grid-row";

type Options = {
/**
* Set focus on first non-input item according to
* tabindex, rather than DOM order.
*/
setFocusOnTabbable?: boolean;
};

/**
* Utilities for managing focus in a data grid.
*/
export class DataGridFocusController implements ReactiveController {
readonly #host: DataGridRow | DataGridCell;

constructor(
host: DataGridRow | DataGridCell,
opts: Options = {
setFocusOnTabbable: false,
},
) {
this.#host = host;
host.addController(this);

this.#host.addEventListener(
"focus",
() => {
if (!this.#host.matches(":focus-visible")) {
// Only handle focus on keyboard tabbing
return;
}

const el = opts.setFocusOnTabbable
? this.firstTabbable
: this.firstFocusable;

if (el) {
if (this.isFocusableInput(el)) {
this.#host.addEventListener("keydown", this.#onFocusForEl(el), {
once: true,
capture: true,
});
} else {
el.focus();
}
}
},
{ passive: true, capture: true },
);
}

hostConnected() {}
hostDisconnected() {}

/**
* Focusable elements in DOM order. This will include
* all focusable elements, including elements with `tabindex="1"`.
*/
public get focusable() {
return focusable(this.#host, {
getShadowRoot: true,
});
}

/**
* Focusable elements in `tabindex` order.
*/
public get tabbable() {
return tabbable(this.#host, {
getShadowRoot: true,
});
}

public get firstFocusable(): FocusableElement | undefined {
return this.focusable[0];
}

public get firstTabbable(): FocusableElement | undefined {
return this.tabbable[0];
}

public isFocusable(el: Element) {
return isFocusable(el);
}

public isTabbable(el: Element) {
return isTabbable(el);
}

public isFocusableInput(el: Element) {
// TODO Handle `<sl-select>`/`<sl-option>`
return el.tagName === "INPUT" && this.isFocusable(el);
}

/**
* Based on recommendations from
* https://www.w3.org/WAI/ARIA/apg/patterns/grid/#keyboardinteraction-settingfocusandnavigatinginsidecells
*/
readonly #onFocusForEl = (el: FocusableElement) => (e: KeyboardEvent) => {
const { key } = e;

switch (key) {
case "Tab": {
// Prevent entering cell
e.preventDefault();
break;
}
case "Enter": {
e.preventDefault();

// Enter cell and focus on input
el.focus();
break;
}
default: {
if (key.length === 1) {
// Enter cell and focus on input
el.focus();
}
break;
}
}
};
}
Loading
Loading