Skip to content

Commit 594f5bc

Browse files
SuaYooemma-sg
andauthored
devex: Data grid component (#2561)
- Adds new `<btrix-data-grid>` component - Refactors `<btrix-usage-history-table>` to data grid - Refactors Refactors `<btrix-syntax-input>` and `<btrix-link-selector-table>` to be form-associated controls. --------- Co-authored-by: Emma Segal-Grossman <[email protected]>
1 parent 6b510fe commit 594f5bc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+2738
-343
lines changed

frontend/custom-elements-manifest.config.mjs

+31-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
export default {
22
/** Globs to analyze */
3-
globs: ["src/**/*.ts"],
3+
globs: ["src/components/**/*.ts", "src/features/**/*.ts"],
44
/** Globs to exclude */
5-
exclude: ["__generated__", "__mocks__"],
5+
exclude: ["src/**/*.stories.ts"],
66
/** Directory to output CEM to */
77
outdir: "src/__generated__",
88
/** Run in dev mode, provides extra logging */
@@ -15,4 +15,33 @@ export default {
1515
packagejson: false,
1616
/** Enable special handling for litelement */
1717
litelement: true,
18+
/** Provide custom plugins */
19+
plugins: [filterPrivateFields()],
1820
};
21+
22+
// Filter private fields
23+
// Based on https://github.com/storybookjs/storybook/issues/15436#issuecomment-1856333227
24+
function filterPrivateFields() {
25+
return {
26+
name: "web-components-private-fields-filter",
27+
analyzePhase({ ts, node, moduleDoc }) {
28+
switch (node.kind) {
29+
case ts.SyntaxKind.ClassDeclaration: {
30+
const className = node.name.getText();
31+
const classDoc = moduleDoc?.declarations?.find(
32+
(declaration) => declaration.name === className,
33+
);
34+
35+
if (classDoc?.members) {
36+
// Filter both private and static members
37+
// TODO May be able to avoid some of this with `#` private member prefix
38+
// https://github.com/webrecorder/browsertrix/issues/2563
39+
classDoc.members = classDoc.members.filter(
40+
(member) => !member.privacy && !member.static,
41+
);
42+
}
43+
}
44+
}
45+
},
46+
};
47+
}

frontend/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"replaywebpage": "^2.2.4",
8080
"slugify": "^1.6.6",
8181
"style-loader": "^3.3.0",
82+
"tabbable": "^6.2.0",
8283
"tailwindcss": "^3.4.1",
8384
"terser-webpack-plugin": "^5.3.10",
8485
"thread-loader": "^4.0.4",
+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// API v1.15.0
2+
export default {
3+
id: "x_example_org_id_x",
4+
name: "Example Org",
5+
slug: "example-org",
6+
users: {
7+
8+
role: 40,
9+
name: "Alice",
10+
11+
},
12+
13+
role: 20,
14+
name: "Bob",
15+
16+
},
17+
18+
role: 20,
19+
name: "Carol",
20+
21+
},
22+
23+
role: 10,
24+
name: "Dave",
25+
26+
},
27+
28+
role: 10,
29+
name: "Eve",
30+
31+
},
32+
},
33+
created: "2023-11-08T17:19:23Z",
34+
default: false,
35+
bytesStored: 22143878904,
36+
bytesStoredCrawls: 22023942070,
37+
bytesStoredUploads: 38872471,
38+
bytesStoredProfiles: 81064363,
39+
origin: null,
40+
storageQuotaReached: false,
41+
execMinutesQuotaReached: false,
42+
usage: {
43+
"2023-11": 473,
44+
"2023-12": 1273,
45+
"2024-01": 4752,
46+
"2024-03": 26,
47+
"2024-04": 398,
48+
"2024-05": 3030,
49+
"2024-06": 2628,
50+
"2024-07": 1655,
51+
"2024-08": 1289,
52+
"2024-10": 308,
53+
"2025-04": 5723,
54+
},
55+
crawlExecSeconds: {
56+
"2023-11": 760,
57+
"2023-12": 1771,
58+
"2024-01": 4939,
59+
"2024-03": 14,
60+
"2024-04": 253,
61+
"2024-05": 2958,
62+
"2024-06": 4276,
63+
"2024-07": 2066,
64+
"2024-08": 2250,
65+
"2024-10": 233,
66+
"2025-01": 1,
67+
"2025-04": 5688,
68+
},
69+
qaUsage: {
70+
"2024-04": 214,
71+
"2024-05": 1526,
72+
"2024-06": 894,
73+
"2024-08": 1188,
74+
"2024-10": 314,
75+
"2025-01": 166,
76+
},
77+
qaCrawlExecSeconds: {
78+
"2024-04": 174,
79+
"2024-05": 1484,
80+
"2024-06": 1914,
81+
"2024-08": 3131,
82+
"2024-10": 943,
83+
"2025-01": 366,
84+
},
85+
monthlyExecSeconds: { "2023-11": 760, "2023-12": 1771, "2024-01": 3000 },
86+
extraExecSeconds: {},
87+
giftedExecSeconds: {},
88+
extraExecSecondsAvailable: 0,
89+
giftedExecSecondsAvailable: 0,
90+
quotas: {
91+
storageQuota: 100000000000,
92+
maxExecMinutesPerMonth: 0,
93+
maxConcurrentCrawls: 10,
94+
maxPagesPerCrawl: 0,
95+
extraExecMinutes: 0,
96+
giftedExecMinutes: 0,
97+
},
98+
quotaUpdates: [
99+
{
100+
modified: "2024-01-18T07:16:22.534000Z",
101+
update: {
102+
storageQuota: 1000000000000,
103+
maxExecMinutesPerMonth: 0,
104+
maxConcurrentCrawls: 10,
105+
maxPagesPerCrawl: 0,
106+
extraExecMinutes: 0,
107+
giftedExecMinutes: 0,
108+
},
109+
},
110+
{
111+
modified: "2024-07-17T15:36:45Z",
112+
update: {
113+
storageQuota: 10000000000,
114+
maxExecMinutesPerMonth: 0,
115+
maxConcurrentCrawls: 10,
116+
maxPagesPerCrawl: 0,
117+
extraExecMinutes: 0,
118+
giftedExecMinutes: 0,
119+
},
120+
},
121+
{
122+
modified: "2024-07-17T15:39:26Z",
123+
update: {
124+
storageQuota: 100000000000,
125+
maxExecMinutesPerMonth: 0,
126+
maxConcurrentCrawls: 10,
127+
maxPagesPerCrawl: 0,
128+
extraExecMinutes: 0,
129+
giftedExecMinutes: 0,
130+
},
131+
},
132+
],
133+
webhookUrls: {
134+
crawlStarted: null,
135+
crawlFinished: null,
136+
crawlDeleted: null,
137+
qaAnalysisStarted: null,
138+
qaAnalysisFinished: null,
139+
crawlReviewed: null,
140+
uploadFinished: null,
141+
uploadDeleted: null,
142+
addedToCollection: null,
143+
removedFromCollection: null,
144+
collectionDeleted: null,
145+
},
146+
readOnly: false,
147+
readOnlyReason: "",
148+
subscription: null,
149+
allowSharedProxies: false,
150+
allowedProxies: ["nz-proxy-1"],
151+
crawlingDefaults: null,
152+
lastCrawlFinished: "2025-04-02T23:00:50Z",
153+
enablePublicProfile: false,
154+
publicDescription: "This is an example org.",
155+
publicUrl: "https://example.com",
156+
};

frontend/src/components/ui/code.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { html as staticHtml, unsafeStatic } from "lit/static-html.js";
88
import { TailwindElement } from "@/classes/TailwindElement";
99
import { tw } from "@/utils/tailwind";
1010

11-
enum Language {
11+
export enum Language {
1212
Javascript = "javascript",
1313
XML = "xml",
1414
CSS = "css",

frontend/src/components/ui/config-details.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,10 @@ export class ConfigDetails extends BtrixElement {
503503
selectors.length
504504
? html`
505505
<div class="mb-2">
506-
<btrix-link-selector-table .selectors=${selectors}>
506+
<btrix-link-selector-table
507+
.selectors=${selectors}
508+
aria-readonly="true"
509+
>
507510
</btrix-link-selector-table>
508511
</div>
509512
`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Directive, type PartInfo } from "lit/directive.js";
2+
3+
import type { DataGridCell } from "./data-grid-cell";
4+
import type { GridColumn } from "./types";
5+
6+
/**
7+
* Directive for replacing `renderCell` and `renderEditCell`
8+
* methods with custom render functions.
9+
*/
10+
export class CellDirective extends Directive {
11+
private readonly element?: DataGridCell;
12+
13+
constructor(partInfo: PartInfo & { element?: DataGridCell }) {
14+
super(partInfo);
15+
this.element = partInfo.element;
16+
}
17+
18+
render(col: GridColumn) {
19+
if (!this.element) return;
20+
21+
if (col.renderCell) {
22+
this.element.renderCell = col.renderCell;
23+
}
24+
25+
if (col.renderEditCell) {
26+
this.element.renderEditCell = col.renderEditCell;
27+
}
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { ReactiveController } from "lit";
2+
import {
3+
focusable,
4+
isFocusable,
5+
isTabbable,
6+
tabbable,
7+
type FocusableElement,
8+
} from "tabbable";
9+
10+
import type { DataGridCell } from "../data-grid-cell";
11+
import type { DataGridRow } from "../data-grid-row";
12+
13+
type Options = {
14+
/**
15+
* Set focus on first non-input item according to
16+
* tabindex, rather than DOM order.
17+
*/
18+
setFocusOnTabbable?: boolean;
19+
};
20+
21+
/**
22+
* Utilities for managing focus in a data grid.
23+
*/
24+
export class DataGridFocusController implements ReactiveController {
25+
readonly #host: DataGridRow | DataGridCell;
26+
27+
constructor(
28+
host: DataGridRow | DataGridCell,
29+
opts: Options = {
30+
setFocusOnTabbable: false,
31+
},
32+
) {
33+
this.#host = host;
34+
host.addController(this);
35+
36+
this.#host.addEventListener(
37+
"focus",
38+
() => {
39+
if (!this.#host.matches(":focus-visible")) {
40+
// Only handle focus on keyboard tabbing
41+
return;
42+
}
43+
44+
const el = opts.setFocusOnTabbable
45+
? this.firstTabbable
46+
: this.firstFocusable;
47+
48+
if (el) {
49+
if (this.isFocusableInput(el)) {
50+
this.#host.addEventListener("keydown", this.#onFocusForEl(el), {
51+
once: true,
52+
capture: true,
53+
});
54+
} else {
55+
el.focus();
56+
}
57+
}
58+
},
59+
{ passive: true, capture: true },
60+
);
61+
}
62+
63+
hostConnected() {}
64+
hostDisconnected() {}
65+
66+
/**
67+
* Focusable elements in DOM order. This will include
68+
* all focusable elements, including elements with `tabindex="1"`.
69+
*/
70+
public get focusable() {
71+
return focusable(this.#host, {
72+
getShadowRoot: true,
73+
});
74+
}
75+
76+
/**
77+
* Focusable elements in `tabindex` order.
78+
*/
79+
public get tabbable() {
80+
return tabbable(this.#host, {
81+
getShadowRoot: true,
82+
});
83+
}
84+
85+
public get firstFocusable(): FocusableElement | undefined {
86+
return this.focusable[0];
87+
}
88+
89+
public get firstTabbable(): FocusableElement | undefined {
90+
return this.tabbable[0];
91+
}
92+
93+
public isFocusable(el: Element) {
94+
return isFocusable(el);
95+
}
96+
97+
public isTabbable(el: Element) {
98+
return isTabbable(el);
99+
}
100+
101+
public isFocusableInput(el: Element) {
102+
// TODO Handle `<sl-select>`/`<sl-option>`
103+
return el.tagName === "INPUT" && this.isFocusable(el);
104+
}
105+
106+
/**
107+
* Based on recommendations from
108+
* https://www.w3.org/WAI/ARIA/apg/patterns/grid/#keyboardinteraction-settingfocusandnavigatinginsidecells
109+
*/
110+
readonly #onFocusForEl = (el: FocusableElement) => (e: KeyboardEvent) => {
111+
const { key } = e;
112+
113+
switch (key) {
114+
case "Tab": {
115+
// Prevent entering cell
116+
e.preventDefault();
117+
break;
118+
}
119+
case "Enter": {
120+
e.preventDefault();
121+
122+
// Enter cell and focus on input
123+
el.focus();
124+
break;
125+
}
126+
default: {
127+
if (key.length === 1) {
128+
// Enter cell and focus on input
129+
el.focus();
130+
}
131+
break;
132+
}
133+
}
134+
};
135+
}

0 commit comments

Comments
 (0)