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

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
203b9fa
add data grid
SuaYoo Apr 22, 2025
32933f5
wip input
SuaYoo Apr 23, 2025
c138bc9
filter out private and static members
SuaYoo Apr 23, 2025
e5a0a75
wip edit cells
SuaYoo Apr 23, 2025
05cfc38
remove unused plugin
SuaYoo Apr 24, 2025
30ddfc8
handle validation
SuaYoo Apr 24, 2025
fa75b4e
wip form control
SuaYoo Apr 25, 2025
910620f
wip validation
SuaYoo Apr 28, 2025
b174b4e
create cell component
SuaYoo Apr 29, 2025
c3b9a8e
add form helper
SuaYoo Apr 29, 2025
8cd11fe
show tooltip on blur
SuaYoo Apr 29, 2025
823a16b
add basic keyboard nav
SuaYoo Apr 29, 2025
7c0a73e
wip accessibility
SuaYoo Apr 29, 2025
5a64e6a
focus accessibility
SuaYoo Apr 29, 2025
89aec76
rename decorator
SuaYoo Apr 29, 2025
2ca6459
migrate usage history table
SuaYoo Apr 29, 2025
e81d842
support separate add and remove rows
SuaYoo Apr 29, 2025
88980a4
support row key
SuaYoo Apr 29, 2025
2b96f20
add deprecation notice
SuaYoo Apr 29, 2025
0880cc6
update comments
SuaYoo Apr 29, 2025
cee9f11
overflow in small screens
SuaYoo Apr 29, 2025
e3fb836
add comment
SuaYoo Apr 29, 2025
b78fb4a
Apply suggestions from code review
SuaYoo Apr 30, 2025
019e0ae
link to refactor issue in comments
SuaYoo Apr 30, 2025
83450ec
use map for id
SuaYoo Apr 30, 2025
8e3d76e
generate id for label
SuaYoo Apr 30, 2025
458b4ac
enable adding more than one row
SuaYoo Apr 30, 2025
f0b204e
Update frontend/src/components/ui/data-grid/data-grid.ts
SuaYoo Apr 30, 2025
692a2e6
fix url input validity check
SuaYoo Apr 30, 2025
69e3feb
revert id caching
SuaYoo Apr 30, 2025
12cd3f2
clean up table
SuaYoo Apr 30, 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: {
"alice@example.com": {
role: 40,
name: "Alice",
email: "alice@example.com",
},
"bob@example.com": {
role: 20,
name: "Bob",
email: "bob@example.com",
},
"carol@example.com": {
role: 20,
name: "Carol",
email: "carol@example.com",
},
"dave@example.com": {
role: 10,
name: "Dave",
email: "dave@example.com",
},
"eve@example.com": {
role: 10,
name: "Eve",
email: "eve@example.com",
},
},
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",
};
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