Compare commits
10 Commits
4bfc49eb25
...
ec0e5ad26c
Author | SHA1 | Date |
---|---|---|
Will Martin | ec0e5ad26c | |
Jake Fink | 6ae086f89a | |
Cesar Gonzalez | 5dc200577c | |
Justin Baur | a8e4366ec0 | |
Matt Gibson | 089f251a0c | |
renovate[bot] | b3242145f9 | |
Justin Baur | b482a15d34 | |
William Martin | 10c9b621c9 | |
William Martin | c4da29bcf4 | |
William Martin | 8fd1ebc2ef |
|
@ -164,6 +164,10 @@ jobs:
|
|||
run: npm run dist:mv3
|
||||
working-directory: browser-source/apps/browser
|
||||
|
||||
- name: Build Chrome Manifest v3 Beta
|
||||
run: npm run dist:chrome:beta
|
||||
working-directory: browser-source/apps/browser
|
||||
|
||||
- name: Gulp
|
||||
run: gulp ci
|
||||
working-directory: browser-source/apps/browser
|
||||
|
@ -196,6 +200,13 @@ jobs:
|
|||
path: browser-source/apps/browser/dist/dist-chrome-mv3.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Chrome MV3 Beta artifact (DO NOT USE FOR PROD)
|
||||
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
||||
with:
|
||||
name: DO-NOT-USE-FOR-PROD-dist-chrome-MV3-beta-${{ env._BUILD_NUMBER }}.zip
|
||||
path: browser-source/apps/browser/dist/dist-chrome-mv3-beta.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Firefox artifact
|
||||
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
||||
with:
|
||||
|
|
|
@ -35,6 +35,9 @@ function buildString() {
|
|||
if (process.env.MANIFEST_VERSION) {
|
||||
build = `-mv${process.env.MANIFEST_VERSION}`;
|
||||
}
|
||||
if (process.env.BETA_BUILD === "1") {
|
||||
build += "-beta";
|
||||
}
|
||||
if (process.env.BUILD_NUMBER && process.env.BUILD_NUMBER !== "") {
|
||||
build = `-${process.env.BUILD_NUMBER}`;
|
||||
}
|
||||
|
@ -65,6 +68,9 @@ function distFirefox() {
|
|||
manifest.optional_permissions = manifest.optional_permissions.filter(
|
||||
(permission) => permission !== "privacy",
|
||||
);
|
||||
if (process.env.BETA_BUILD === "1") {
|
||||
manifest = applyBetaLabels(manifest);
|
||||
}
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
|
@ -72,6 +78,9 @@ function distFirefox() {
|
|||
function distOpera() {
|
||||
return dist("opera", (manifest) => {
|
||||
delete manifest.applications;
|
||||
if (process.env.BETA_BUILD === "1") {
|
||||
manifest = applyBetaLabels(manifest);
|
||||
}
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
|
@ -81,6 +90,9 @@ function distChrome() {
|
|||
delete manifest.applications;
|
||||
delete manifest.sidebar_action;
|
||||
delete manifest.commands._execute_sidebar_action;
|
||||
if (process.env.BETA_BUILD === "1") {
|
||||
manifest = applyBetaLabels(manifest);
|
||||
}
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
|
@ -90,6 +102,9 @@ function distEdge() {
|
|||
delete manifest.applications;
|
||||
delete manifest.sidebar_action;
|
||||
delete manifest.commands._execute_sidebar_action;
|
||||
if (process.env.BETA_BUILD === "1") {
|
||||
manifest = applyBetaLabels(manifest);
|
||||
}
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
|
@ -210,6 +225,9 @@ async function safariCopyBuild(source, dest) {
|
|||
delete manifest.commands._execute_sidebar_action;
|
||||
delete manifest.optional_permissions;
|
||||
manifest.permissions.push("nativeMessaging");
|
||||
if (process.env.BETA_BUILD === "1") {
|
||||
manifest = applyBetaLabels(manifest);
|
||||
}
|
||||
return manifest;
|
||||
}),
|
||||
),
|
||||
|
@ -235,6 +253,19 @@ async function ciCoverage(cb) {
|
|||
.pipe(gulp.dest(paths.coverage));
|
||||
}
|
||||
|
||||
function applyBetaLabels(manifest) {
|
||||
manifest.name = "Bitwarden Password Manager BETA";
|
||||
manifest.short_name = "Bitwarden BETA";
|
||||
manifest.description = "THIS EXTENSION IS FOR BETA TESTING BITWARDEN.";
|
||||
if (process.env.GITHUB_RUN_ID) {
|
||||
manifest.version_name = `${manifest.version} beta - ${process.env.GITHUB_SHA.slice(0, 8)}`;
|
||||
manifest.version = `${manifest.version}.${parseInt(process.env.GITHUB_RUN_ID.slice(-4))}`;
|
||||
} else {
|
||||
manifest.version = `${manifest.version}.0`;
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
exports["dist:firefox"] = distFirefox;
|
||||
exports["dist:chrome"] = distChrome;
|
||||
exports["dist:opera"] = distOpera;
|
||||
|
|
|
@ -7,10 +7,14 @@
|
|||
"build:watch": "webpack --watch",
|
||||
"build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch",
|
||||
"build:prod": "cross-env NODE_ENV=production webpack",
|
||||
"build:prod:beta": "cross-env BETA_BUILD=1 NODE_ENV=production webpack",
|
||||
"build:prod:watch": "cross-env NODE_ENV=production webpack --watch",
|
||||
"dist": "npm run build:prod && gulp dist",
|
||||
"dist:beta": "npm run build:prod:beta && cross-env BETA_BUILD=1 gulp dist",
|
||||
"dist:mv3": "cross-env MANIFEST_VERSION=3 npm run build:prod && cross-env MANIFEST_VERSION=3 gulp dist",
|
||||
"dist:mv3:beta": "cross-env MANIFEST_VERSION=3 npm run build:prod:beta && cross-env MANIFEST_VERSION=3 BETA_BUILD=1 gulp dist",
|
||||
"dist:chrome": "npm run build:prod && gulp dist:chrome",
|
||||
"dist:chrome:beta": "cross-env MANIFEST_VERSION=3 npm run build:prod:beta && cross-env MANIFEST_VERSION=3 BETA_BUILD=1 gulp dist:chrome",
|
||||
"dist:firefox": "npm run build:prod && gulp dist:firefox",
|
||||
"dist:opera": "npm run build:prod && gulp dist:opera",
|
||||
"dist:safari": "npm run build:prod && gulp dist:safari",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Subject, firstValueFrom, merge } from "rxjs";
|
||||
import { Subject, firstValueFrom, merge, timeout } from "rxjs";
|
||||
|
||||
import {
|
||||
PinCryptoServiceAbstraction,
|
||||
|
@ -490,7 +490,7 @@ export default class MainBackground {
|
|||
this.accountService,
|
||||
this.singleUserStateProvider,
|
||||
);
|
||||
this.derivedStateProvider = new BackgroundDerivedStateProvider(storageServiceProvider);
|
||||
this.derivedStateProvider = new BackgroundDerivedStateProvider();
|
||||
this.stateProvider = new DefaultStateProvider(
|
||||
this.activeUserStateProvider,
|
||||
this.singleUserStateProvider,
|
||||
|
@ -1196,7 +1196,18 @@ export default class MainBackground {
|
|||
}
|
||||
|
||||
async logout(expired: boolean, userId?: UserId) {
|
||||
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
userId ??= (
|
||||
await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
timeout({
|
||||
first: 2000,
|
||||
with: () => {
|
||||
throw new Error("No active account found to logout");
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
)?.id;
|
||||
|
||||
await this.eventUploadService.uploadEvents(userId as UserId);
|
||||
|
||||
|
|
|
@ -3,15 +3,10 @@ import { DerivedStateProvider } from "@bitwarden/common/platform/state";
|
|||
import { BackgroundDerivedStateProvider } from "../../state/background-derived-state.provider";
|
||||
|
||||
import { CachedServices, FactoryOptions, factory } from "./factory-options";
|
||||
import {
|
||||
StorageServiceProviderInitOptions,
|
||||
storageServiceProviderFactory,
|
||||
} from "./storage-service-provider.factory";
|
||||
|
||||
type DerivedStateProviderFactoryOptions = FactoryOptions;
|
||||
|
||||
export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions &
|
||||
StorageServiceProviderInitOptions;
|
||||
export type DerivedStateProviderInitOptions = DerivedStateProviderFactoryOptions;
|
||||
|
||||
export async function derivedStateProviderFactory(
|
||||
cache: { derivedStateProvider?: DerivedStateProvider } & CachedServices,
|
||||
|
@ -21,7 +16,6 @@ export async function derivedStateProviderFactory(
|
|||
cache,
|
||||
"derivedStateProvider",
|
||||
opts,
|
||||
async () =>
|
||||
new BackgroundDerivedStateProvider(await storageServiceProviderFactory(cache, opts)),
|
||||
async () => new BackgroundDerivedStateProvider(),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
|
||||
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
|
||||
|
@ -16,14 +12,11 @@ export class BackgroundDerivedStateProvider extends DefaultDerivedStateProvider
|
|||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
storageLocation: [string, AbstractStorageService & ObservableStorageService],
|
||||
): DerivedState<TTo> {
|
||||
const [location, storageService] = storageLocation;
|
||||
return new BackgroundDerivedState(
|
||||
parentState$,
|
||||
deriveDefinition,
|
||||
storageService,
|
||||
deriveDefinition.buildCacheKey(location),
|
||||
deriveDefinition.buildCacheKey(),
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import { Observable, Subscription } from "rxjs";
|
||||
import { Observable, Subscription, concatMap } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { DeriveDefinition } from "@bitwarden/common/platform/state";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
|
||||
import { DefaultDerivedState } from "@bitwarden/common/platform/state/implementations/default-derived-state";
|
||||
|
@ -22,11 +19,10 @@ export class BackgroundDerivedState<
|
|||
constructor(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
memoryStorage: AbstractStorageService & ObservableStorageService,
|
||||
portName: string,
|
||||
dependencies: TDeps,
|
||||
) {
|
||||
super(parentState$, deriveDefinition, memoryStorage, dependencies);
|
||||
super(parentState$, deriveDefinition, dependencies);
|
||||
|
||||
// listen for foreground derived states to connect
|
||||
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
|
||||
|
@ -42,7 +38,20 @@ export class BackgroundDerivedState<
|
|||
});
|
||||
port.onMessage.addListener(listenerCallback);
|
||||
|
||||
const stateSubscription = this.state$.subscribe();
|
||||
const stateSubscription = this.state$
|
||||
.pipe(
|
||||
concatMap(async (state) => {
|
||||
await this.sendMessage(
|
||||
{
|
||||
action: "nextState",
|
||||
data: JSON.stringify(state),
|
||||
id: Utils.newGuid(),
|
||||
},
|
||||
port,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.portSubscriptions.set(port, stateSubscription);
|
||||
});
|
||||
|
|
|
@ -4,14 +4,13 @@
|
|||
*/
|
||||
|
||||
import { NgZone } from "@angular/core";
|
||||
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
|
||||
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec/utils";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Subject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { DeriveDefinition } from "@bitwarden/common/platform/state";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition
|
||||
import { StateDefinition } from "@bitwarden/common/platform/state/state-definition";
|
||||
import { awaitAsync, trackEmissions, ObservableTracker } from "@bitwarden/common/spec";
|
||||
|
||||
import { mockPorts } from "../../../spec/mock-port.spec-util";
|
||||
|
||||
|
@ -22,6 +21,7 @@ const stateDefinition = new StateDefinition("test", "memory");
|
|||
const deriveDefinition = new DeriveDefinition(stateDefinition, "test", {
|
||||
derive: (dateString: string) => (dateString == null ? null : new Date(dateString)),
|
||||
deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)),
|
||||
cleanupDelayMs: 1000,
|
||||
});
|
||||
|
||||
// Mock out the runInsideAngular operator so we don't have to deal with zone.js
|
||||
|
@ -35,7 +35,6 @@ describe("foreground background derived state interactions", () => {
|
|||
let foreground: ForegroundDerivedState<Date>;
|
||||
let background: BackgroundDerivedState<string, Date, Record<string, unknown>>;
|
||||
let parentState$: Subject<string>;
|
||||
let memoryStorage: FakeStorageService;
|
||||
const initialParent = "2020-01-01";
|
||||
const ngZone = mock<NgZone>();
|
||||
const portName = "testPort";
|
||||
|
@ -43,16 +42,9 @@ describe("foreground background derived state interactions", () => {
|
|||
beforeEach(() => {
|
||||
mockPorts();
|
||||
parentState$ = new Subject<string>();
|
||||
memoryStorage = new FakeStorageService();
|
||||
|
||||
background = new BackgroundDerivedState(
|
||||
parentState$,
|
||||
deriveDefinition,
|
||||
memoryStorage,
|
||||
portName,
|
||||
{},
|
||||
);
|
||||
foreground = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
|
||||
background = new BackgroundDerivedState(parentState$, deriveDefinition, portName, {});
|
||||
foreground = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -72,21 +64,13 @@ describe("foreground background derived state interactions", () => {
|
|||
});
|
||||
|
||||
it("should initialize a late-connected foreground", async () => {
|
||||
const newForeground = new ForegroundDerivedState(
|
||||
deriveDefinition,
|
||||
memoryStorage,
|
||||
portName,
|
||||
ngZone,
|
||||
);
|
||||
const backgroundEmissions = trackEmissions(background.state$);
|
||||
const newForeground = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
|
||||
const backgroundTracker = new ObservableTracker(background.state$);
|
||||
parentState$.next(initialParent);
|
||||
await awaitAsync();
|
||||
const foregroundTracker = new ObservableTracker(newForeground.state$);
|
||||
|
||||
const foregroundEmissions = trackEmissions(newForeground.state$);
|
||||
await awaitAsync(10);
|
||||
|
||||
expect(backgroundEmissions).toEqual([new Date(initialParent)]);
|
||||
expect(foregroundEmissions).toEqual([new Date(initialParent)]);
|
||||
expect(await backgroundTracker.expectEmission()).toEqual(new Date(initialParent));
|
||||
expect(await foregroundTracker.expectEmission()).toEqual(new Date(initialParent));
|
||||
});
|
||||
|
||||
describe("forceValue", () => {
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import { NgZone } from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client
|
||||
import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider";
|
||||
|
@ -14,23 +9,17 @@ import { DerivedStateDependencies } from "@bitwarden/common/src/types/state";
|
|||
import { ForegroundDerivedState } from "./foreground-derived-state";
|
||||
|
||||
export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider {
|
||||
constructor(
|
||||
storageServiceProvider: StorageServiceProvider,
|
||||
private ngZone: NgZone,
|
||||
) {
|
||||
super(storageServiceProvider);
|
||||
constructor(private ngZone: NgZone) {
|
||||
super();
|
||||
}
|
||||
override buildDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
_parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
_dependencies: TDeps,
|
||||
storageLocation: [string, AbstractStorageService & ObservableStorageService],
|
||||
): DerivedState<TTo> {
|
||||
const [location, storageService] = storageLocation;
|
||||
return new ForegroundDerivedState(
|
||||
deriveDefinition,
|
||||
storageService,
|
||||
deriveDefinition.buildCacheKey(location),
|
||||
deriveDefinition.buildCacheKey(),
|
||||
this.ngZone,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
/**
|
||||
* need to update test environment so structuredClone works appropriately
|
||||
* @jest-environment ../../libs/shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { NgZone } from "@angular/core";
|
||||
import { awaitAsync, trackEmissions } from "@bitwarden/common/../spec";
|
||||
import { FakeStorageService } from "@bitwarden/common/../spec/fake-storage.service";
|
||||
import { awaitAsync } from "@bitwarden/common/../spec";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { DeriveDefinition } from "@bitwarden/common/platform/state";
|
||||
|
@ -32,15 +26,12 @@ jest.mock("../browser/run-inside-angular.operator", () => {
|
|||
|
||||
describe("ForegroundDerivedState", () => {
|
||||
let sut: ForegroundDerivedState<Date>;
|
||||
let memoryStorage: FakeStorageService;
|
||||
const portName = "testPort";
|
||||
const ngZone = mock<NgZone>();
|
||||
|
||||
beforeEach(() => {
|
||||
memoryStorage = new FakeStorageService();
|
||||
memoryStorage.internalUpdateValuesRequireDeserialization(true);
|
||||
mockPorts();
|
||||
sut = new ForegroundDerivedState(deriveDefinition, memoryStorage, portName, ngZone);
|
||||
sut = new ForegroundDerivedState(deriveDefinition, portName, ngZone);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -67,18 +58,4 @@ describe("ForegroundDerivedState", () => {
|
|||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
expect(sut["port"]).toBeNull();
|
||||
});
|
||||
|
||||
it("should emit when the memory storage updates", async () => {
|
||||
const dateString = "2020-01-01";
|
||||
const emissions = trackEmissions(sut.state$);
|
||||
|
||||
await memoryStorage.save(deriveDefinition.storageKey, {
|
||||
derived: true,
|
||||
value: new Date(dateString),
|
||||
});
|
||||
|
||||
await awaitAsync();
|
||||
|
||||
expect(emissions).toEqual([new Date(dateString)]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,19 +6,14 @@ import {
|
|||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
share,
|
||||
switchMap,
|
||||
tap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
import { Jsonify, JsonObject } from "type-fest";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state";
|
||||
import { DerivedStateDependencies } from "@bitwarden/common/types/state";
|
||||
|
@ -27,41 +22,28 @@ import { fromChromeEvent } from "../browser/from-chrome-event";
|
|||
import { runInsideAngular } from "../browser/run-inside-angular.operator";
|
||||
|
||||
export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
|
||||
private storageKey: string;
|
||||
private port: chrome.runtime.Port;
|
||||
private backgroundResponses$: Observable<DerivedStateMessage>;
|
||||
state$: Observable<TTo>;
|
||||
|
||||
constructor(
|
||||
private deriveDefinition: DeriveDefinition<unknown, TTo, DerivedStateDependencies>,
|
||||
private memoryStorage: AbstractStorageService & ObservableStorageService,
|
||||
private portName: string,
|
||||
private ngZone: NgZone,
|
||||
) {
|
||||
this.storageKey = deriveDefinition.storageKey;
|
||||
|
||||
const initialStorageGet$ = defer(() => {
|
||||
return this.getStoredValue();
|
||||
}).pipe(
|
||||
filter((s) => s.derived),
|
||||
map((s) => s.value),
|
||||
);
|
||||
|
||||
const latestStorage$ = this.memoryStorage.updates$.pipe(
|
||||
filter((s) => s.key === this.storageKey),
|
||||
switchMap(async (storageUpdate) => {
|
||||
if (storageUpdate.updateType === "remove") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.getStoredValue();
|
||||
}),
|
||||
filter((s) => s.derived),
|
||||
map((s) => s.value),
|
||||
);
|
||||
const latestValueFromPort$ = (port: chrome.runtime.Port) => {
|
||||
return fromChromeEvent(port.onMessage).pipe(
|
||||
map(([message]) => message as DerivedStateMessage),
|
||||
filter((message) => message.originator === "background" && message.action === "nextState"),
|
||||
map((message) => {
|
||||
const json = JSON.parse(message.data) as Jsonify<TTo>;
|
||||
return this.deriveDefinition.deserialize(json);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
this.state$ = defer(() => of(this.initializePort())).pipe(
|
||||
switchMap(() => merge(initialStorageGet$, latestStorage$)),
|
||||
switchMap(() => latestValueFromPort$(this.port)),
|
||||
share({
|
||||
connector: () => new ReplaySubject<TTo>(1),
|
||||
resetOnRefCountZero: () =>
|
||||
|
@ -130,28 +112,4 @@ export class ForegroundDerivedState<TTo> implements DerivedState<TTo> {
|
|||
this.port = null;
|
||||
this.backgroundResponses$ = null;
|
||||
}
|
||||
|
||||
protected async getStoredValue(): Promise<{ derived: boolean; value: TTo | null }> {
|
||||
if (this.memoryStorage.valuesRequireDeserialization) {
|
||||
const storedJson = await this.memoryStorage.get<
|
||||
Jsonify<{ derived: true; value: JsonObject }>
|
||||
>(this.storageKey);
|
||||
|
||||
if (!storedJson?.derived) {
|
||||
return { derived: false, value: null };
|
||||
}
|
||||
|
||||
const value = this.deriveDefinition.deserialize(storedJson.value as any);
|
||||
|
||||
return { derived: true, value };
|
||||
} else {
|
||||
const stored = await this.memoryStorage.get<{ derived: true; value: TTo }>(this.storageKey);
|
||||
|
||||
if (!stored?.derived) {
|
||||
return { derived: false, value: null };
|
||||
}
|
||||
|
||||
return { derived: true, value: stored.value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -473,7 +473,7 @@ const safeProviders: SafeProvider[] = [
|
|||
safeProvider({
|
||||
provide: DerivedStateProvider,
|
||||
useClass: ForegroundDerivedStateProvider,
|
||||
deps: [StorageServiceProvider, NgZone],
|
||||
deps: [NgZone],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AutofillSettingsServiceAbstraction,
|
||||
|
|
|
@ -21,6 +21,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
|
|||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
|
@ -86,6 +87,7 @@ export class SettingsComponent implements OnInit {
|
|||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private policyService: PolicyService,
|
||||
private formBuilder: FormBuilder,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
|
@ -434,8 +436,9 @@ export class SettingsComponent implements OnInit {
|
|||
type: "info",
|
||||
});
|
||||
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (confirmed) {
|
||||
this.messagingService.send("logout");
|
||||
this.messagingService.send("logout", { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -314,7 +314,7 @@ export class Main {
|
|||
this.singleUserStateProvider,
|
||||
);
|
||||
|
||||
this.derivedStateProvider = new DefaultDerivedStateProvider(storageServiceProvider);
|
||||
this.derivedStateProvider = new DefaultDerivedStateProvider();
|
||||
|
||||
this.stateProvider = new DefaultStateProvider(
|
||||
this.activeUserStateProvider,
|
||||
|
|
|
@ -157,7 +157,7 @@ export class Main {
|
|||
activeUserStateProvider,
|
||||
singleUserStateProvider,
|
||||
globalStateProvider,
|
||||
new DefaultDerivedStateProvider(storageServiceProvider),
|
||||
new DefaultDerivedStateProvider(),
|
||||
);
|
||||
|
||||
this.environmentService = new DefaultEnvironmentService(stateProvider, accountService);
|
||||
|
|
|
@ -1047,7 +1047,7 @@ const safeProviders: SafeProvider[] = [
|
|||
safeProvider({
|
||||
provide: DerivedStateProvider,
|
||||
useClass: DefaultDerivedStateProvider,
|
||||
deps: [StorageServiceProvider],
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: StateProvider,
|
||||
|
|
|
@ -249,11 +249,11 @@ export class FakeDerivedStateProvider implements DerivedStateProvider {
|
|||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
let result = this.states.get(deriveDefinition.buildCacheKey("memory")) as DerivedState<TTo>;
|
||||
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
this.states.set(deriveDefinition.buildCacheKey("memory"), result);
|
||||
this.states.set(deriveDefinition.buildCacheKey(), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -5,3 +5,4 @@ export * from "./fake-state-provider";
|
|||
export * from "./fake-state";
|
||||
export * from "./fake-account-service";
|
||||
export * from "./fake-storage.service";
|
||||
export * from "./observable-tracker";
|
||||
|
|
|
@ -16,9 +16,11 @@ export class ObservableTracker<T> {
|
|||
/**
|
||||
* Awaits the next emission from the observable, or throws if the timeout is exceeded
|
||||
* @param msTimeout The maximum time to wait for another emission before throwing
|
||||
* @returns The next emission from the observable
|
||||
* @throws If the timeout is exceeded
|
||||
*/
|
||||
async expectEmission(msTimeout = 50) {
|
||||
await firstValueFrom(
|
||||
async expectEmission(msTimeout = 50): Promise<T> {
|
||||
return await firstValueFrom(
|
||||
this.observable.pipe(
|
||||
timeout({
|
||||
first: msTimeout,
|
||||
|
|
|
@ -10,7 +10,7 @@ import { CryptoService } from "../abstractions/crypto.service";
|
|||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
import { I18nService } from "../abstractions/i18n.service";
|
||||
|
||||
const nodeURL = typeof window === "undefined" ? require("url") : null;
|
||||
const nodeURL = typeof self === "undefined" ? require("url") : null;
|
||||
|
||||
declare global {
|
||||
/* eslint-disable-next-line no-var */
|
||||
|
|
|
@ -171,8 +171,8 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
|
|||
return this.options.clearOnCleanup ?? true;
|
||||
}
|
||||
|
||||
buildCacheKey(location: string): string {
|
||||
return `derived_${location}_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
|
||||
buildCacheKey(): string {
|
||||
return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import { Observable } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../../types/state";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { StorageServiceProvider } from "../../services/storage-service.provider";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { DerivedState } from "../derived-state";
|
||||
import { DerivedStateProvider } from "../derived-state.provider";
|
||||
|
@ -15,18 +10,14 @@ import { DefaultDerivedState } from "./default-derived-state";
|
|||
export class DefaultDerivedStateProvider implements DerivedStateProvider {
|
||||
private cache: Record<string, DerivedState<unknown>> = {};
|
||||
|
||||
constructor(protected storageServiceProvider: StorageServiceProvider) {}
|
||||
constructor() {}
|
||||
|
||||
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
// TODO: we probably want to support optional normal memory storage for browser
|
||||
const [location, storageService] = this.storageServiceProvider.get("memory", {
|
||||
browser: "memory-large-object",
|
||||
});
|
||||
const cacheKey = deriveDefinition.buildCacheKey(location);
|
||||
const cacheKey = deriveDefinition.buildCacheKey();
|
||||
const existingDerivedState = this.cache[cacheKey];
|
||||
if (existingDerivedState != null) {
|
||||
// I have to cast out of the unknown generic but this should be safe if rules
|
||||
|
@ -34,10 +25,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
|
|||
return existingDerivedState as DefaultDerivedState<TFrom, TTo, TDeps>;
|
||||
}
|
||||
|
||||
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies, [
|
||||
location,
|
||||
storageService,
|
||||
]);
|
||||
const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
this.cache[cacheKey] = newDerivedState;
|
||||
return newDerivedState;
|
||||
}
|
||||
|
@ -46,13 +34,7 @@ export class DefaultDerivedStateProvider implements DerivedStateProvider {
|
|||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
storageLocation: [string, AbstractStorageService & ObservableStorageService],
|
||||
): DerivedState<TTo> {
|
||||
return new DefaultDerivedState<TFrom, TTo, TDeps>(
|
||||
parentState$,
|
||||
deriveDefinition,
|
||||
storageLocation[1],
|
||||
dependencies,
|
||||
);
|
||||
return new DefaultDerivedState<TFrom, TTo, TDeps>(parentState$, deriveDefinition, dependencies);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
import { Subject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { awaitAsync, trackEmissions } from "../../../../spec";
|
||||
import { FakeStorageService } from "../../../../spec/fake-storage.service";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { StateDefinition } from "../state-definition";
|
||||
|
||||
|
@ -29,7 +28,6 @@ const deriveDefinition = new DeriveDefinition<string, Date, { date: Date }>(
|
|||
|
||||
describe("DefaultDerivedState", () => {
|
||||
let parentState$: Subject<string>;
|
||||
let memoryStorage: FakeStorageService;
|
||||
let sut: DefaultDerivedState<string, Date, { date: Date }>;
|
||||
const deps = {
|
||||
date: new Date(),
|
||||
|
@ -38,8 +36,7 @@ describe("DefaultDerivedState", () => {
|
|||
beforeEach(() => {
|
||||
callCount = 0;
|
||||
parentState$ = new Subject();
|
||||
memoryStorage = new FakeStorageService();
|
||||
sut = new DefaultDerivedState(parentState$, deriveDefinition, memoryStorage, deps);
|
||||
sut = new DefaultDerivedState(parentState$, deriveDefinition, deps);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -66,71 +63,33 @@ describe("DefaultDerivedState", () => {
|
|||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should store the derived state in memory", async () => {
|
||||
const dateString = "2020-01-01";
|
||||
trackEmissions(sut.state$);
|
||||
parentState$.next(dateString);
|
||||
await awaitAsync();
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(new Date(dateString)),
|
||||
);
|
||||
const calls = memoryStorage.mock.save.mock.calls;
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0][0]).toBe(deriveDefinition.storageKey);
|
||||
expect(calls[0][1]).toEqual(derivedValue(new Date(dateString)));
|
||||
});
|
||||
|
||||
describe("forceValue", () => {
|
||||
const initialParentValue = "2020-01-01";
|
||||
const forced = new Date("2020-02-02");
|
||||
let emissions: Date[];
|
||||
|
||||
describe("without observers", () => {
|
||||
beforeEach(async () => {
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
});
|
||||
|
||||
it("should store the forced value", async () => {
|
||||
await sut.forceValue(forced);
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(forced),
|
||||
);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
emissions = trackEmissions(sut.state$);
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
});
|
||||
|
||||
describe("with observers", () => {
|
||||
beforeEach(async () => {
|
||||
emissions = trackEmissions(sut.state$);
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
});
|
||||
it("should force the value", async () => {
|
||||
await sut.forceValue(forced);
|
||||
expect(emissions).toEqual([new Date(initialParentValue), forced]);
|
||||
});
|
||||
|
||||
it("should store the forced value", async () => {
|
||||
await sut.forceValue(forced);
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(forced),
|
||||
);
|
||||
});
|
||||
it("should only force the value once", async () => {
|
||||
await sut.forceValue(forced);
|
||||
|
||||
it("should force the value", async () => {
|
||||
await sut.forceValue(forced);
|
||||
expect(emissions).toEqual([new Date(initialParentValue), forced]);
|
||||
});
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
|
||||
it("should only force the value once", async () => {
|
||||
await sut.forceValue(forced);
|
||||
|
||||
parentState$.next(initialParentValue);
|
||||
await awaitAsync();
|
||||
|
||||
expect(emissions).toEqual([
|
||||
new Date(initialParentValue),
|
||||
forced,
|
||||
new Date(initialParentValue),
|
||||
]);
|
||||
});
|
||||
expect(emissions).toEqual([
|
||||
new Date(initialParentValue),
|
||||
forced,
|
||||
new Date(initialParentValue),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -148,42 +107,6 @@ describe("DefaultDerivedState", () => {
|
|||
expect(parentState$.observed).toBe(false);
|
||||
});
|
||||
|
||||
it("should clear state after cleanup", async () => {
|
||||
const subscription = sut.state$.subscribe();
|
||||
parentState$.next(newDate);
|
||||
await awaitAsync();
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(new Date(newDate)),
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
// Wait for cleanup
|
||||
await awaitAsync(cleanupDelayMs * 2);
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not clear state after cleanup if clearOnCleanup is false", async () => {
|
||||
deriveDefinition.options.clearOnCleanup = false;
|
||||
|
||||
const subscription = sut.state$.subscribe();
|
||||
parentState$.next(newDate);
|
||||
await awaitAsync();
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(new Date(newDate)),
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
// Wait for cleanup
|
||||
await awaitAsync(cleanupDelayMs * 2);
|
||||
|
||||
expect(memoryStorage.internalStore[deriveDefinition.storageKey]).toEqual(
|
||||
derivedValue(new Date(newDate)),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not cleanup if there are still subscribers", async () => {
|
||||
const subscription1 = sut.state$.subscribe();
|
||||
const sub2Emissions: Date[] = [];
|
||||
|
@ -260,7 +183,3 @@ describe("DefaultDerivedState", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
function derivedValue<T>(value: T) {
|
||||
return { derived: true, value };
|
||||
}
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs";
|
||||
|
||||
import { DerivedStateDependencies } from "../../../types/state";
|
||||
import {
|
||||
AbstractStorageService,
|
||||
ObservableStorageService,
|
||||
} from "../../abstractions/storage.service";
|
||||
import { DeriveDefinition } from "../derive-definition";
|
||||
import { DerivedState } from "../derived-state";
|
||||
|
||||
|
@ -22,7 +18,6 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
|
|||
constructor(
|
||||
private parentState$: Observable<TFrom>,
|
||||
protected deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
private memoryStorage: AbstractStorageService & ObservableStorageService,
|
||||
private dependencies: TDeps,
|
||||
) {
|
||||
this.storageKey = deriveDefinition.storageKey;
|
||||
|
@ -34,7 +29,6 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
|
|||
derivedStateOrPromise = await derivedStateOrPromise;
|
||||
}
|
||||
const derivedState = derivedStateOrPromise;
|
||||
await this.storeValue(derivedState);
|
||||
return derivedState;
|
||||
}),
|
||||
);
|
||||
|
@ -44,26 +38,13 @@ export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependenc
|
|||
connector: () => {
|
||||
return new ReplaySubject<TTo>(1);
|
||||
},
|
||||
resetOnRefCountZero: () =>
|
||||
timer(this.deriveDefinition.cleanupDelayMs).pipe(
|
||||
concatMap(async () => {
|
||||
if (this.deriveDefinition.clearOnCleanup) {
|
||||
await this.memoryStorage.remove(this.storageKey);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
),
|
||||
resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async forceValue(value: TTo) {
|
||||
await this.storeValue(value);
|
||||
this.forcedValueSubject.next(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private storeValue(value: TTo) {
|
||||
return this.memoryStorage.save(this.storageKey, { derived: true, value });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { AfterContentChecked, ContentChild, Directive, HostBinding } from "@angular/core";
|
||||
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
@Directive({
|
||||
selector: "bitA11yCell",
|
||||
standalone: true,
|
||||
})
|
||||
export class A11yCellDirective implements AfterContentChecked {
|
||||
@HostBinding("attr.role")
|
||||
role = "gridcell";
|
||||
|
||||
@ContentChild(FocusableElement)
|
||||
focusableChild: FocusableElement;
|
||||
|
||||
ngAfterContentChecked(): void {
|
||||
if (!this.focusableChild) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("A11yCellDirective must contain content that provides FocusableElement");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
import {
|
||||
AfterViewInit,
|
||||
ContentChildren,
|
||||
Directive,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
QueryList,
|
||||
} from "@angular/core";
|
||||
|
||||
import type { A11yCellDirective } from "./a11y-cell.directive";
|
||||
import { A11yRowDirective } from "./a11y-row.directive";
|
||||
|
||||
@Directive({
|
||||
selector: "bitA11yGrid",
|
||||
standalone: true,
|
||||
})
|
||||
export class A11yGridDirective implements AfterViewInit {
|
||||
@HostBinding("attr.role")
|
||||
role = "grid";
|
||||
|
||||
@ContentChildren(A11yRowDirective)
|
||||
rows: QueryList<A11yRowDirective>;
|
||||
|
||||
/** The number of pages to navigate on `PageUp` and `PageDown` */
|
||||
@Input() pageSize = 5;
|
||||
|
||||
private grid: A11yCellDirective[][];
|
||||
|
||||
/** The row that currently has focus */
|
||||
private activeRow = 0;
|
||||
|
||||
/** The cell that currently has focus */
|
||||
private activeCol = 0;
|
||||
|
||||
@HostListener("keydown", ["$event"])
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
switch (event.code) {
|
||||
case "ArrowUp":
|
||||
this.updateCellFocusByDelta(-1, 0);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
this.updateCellFocusByDelta(0, 1);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
this.updateCellFocusByDelta(1, 0);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
this.updateCellFocusByDelta(0, -1);
|
||||
break;
|
||||
case "Home":
|
||||
this.updateCellFocusByDelta(-this.activeRow, -this.activeCol);
|
||||
break;
|
||||
case "End":
|
||||
this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length);
|
||||
break;
|
||||
case "PageUp":
|
||||
this.updateCellFocusByDelta(-this.pageSize, 0);
|
||||
break;
|
||||
case "PageDown":
|
||||
this.updateCellFocusByDelta(this.pageSize, 0);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
/** Prevent default scrolling behavior */
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.initializeGrid();
|
||||
}
|
||||
|
||||
private initializeGrid(): void {
|
||||
this.grid = this.rows.map((listItem) => [...listItem.cells]);
|
||||
this.grid.flat().map((cell) => (cell.focusableChild.getFocusTarget().tabIndex = -1));
|
||||
|
||||
this.getActiveCellContent().tabIndex = 0;
|
||||
}
|
||||
|
||||
/** Get the focusable content of the active cell */
|
||||
private getActiveCellContent(): HTMLElement {
|
||||
return this.grid[this.activeRow][this.activeCol].focusableChild.getFocusTarget();
|
||||
}
|
||||
|
||||
/** Move focus via a delta against the currently active gridcell */
|
||||
private updateCellFocusByDelta(rowDelta: number, colDelta: number) {
|
||||
const prevActive = this.getActiveCellContent();
|
||||
|
||||
this.activeCol += colDelta;
|
||||
this.activeRow += rowDelta;
|
||||
|
||||
// Row upper bound
|
||||
if (this.activeRow >= this.grid.length) {
|
||||
this.activeRow = this.grid.length - 1;
|
||||
}
|
||||
|
||||
// Row lower bound
|
||||
if (this.activeRow < 0) {
|
||||
this.activeRow = 0;
|
||||
}
|
||||
|
||||
// Column upper bound
|
||||
if (this.activeCol >= this.grid[this.activeRow].length) {
|
||||
if (this.activeRow < this.grid.length - 1) {
|
||||
// Wrap to next row on right arrow
|
||||
this.activeCol = 0;
|
||||
this.activeRow += 1;
|
||||
} else {
|
||||
this.activeCol = this.grid[this.activeRow].length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Column lower bound
|
||||
if (this.activeCol < 0) {
|
||||
if (this.activeRow > 0) {
|
||||
// Wrap to prev row on left arrow
|
||||
this.activeRow -= 1;
|
||||
this.activeCol = this.grid[this.activeRow].length - 1;
|
||||
} else {
|
||||
this.activeCol = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const nextActive = this.getActiveCellContent();
|
||||
nextActive.tabIndex = 0;
|
||||
nextActive.focus();
|
||||
|
||||
if (nextActive !== prevActive) {
|
||||
prevActive.tabIndex = -1;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import {
|
||||
AfterViewInit,
|
||||
ContentChildren,
|
||||
Directive,
|
||||
HostBinding,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
} from "@angular/core";
|
||||
|
||||
import { A11yCellDirective } from "./a11y-cell.directive";
|
||||
|
||||
@Directive({
|
||||
selector: "bitA11yRow",
|
||||
standalone: true,
|
||||
})
|
||||
export class A11yRowDirective implements AfterViewInit {
|
||||
@HostBinding("attr.role")
|
||||
role = "row";
|
||||
|
||||
cells: A11yCellDirective[];
|
||||
|
||||
@ViewChildren(A11yCellDirective)
|
||||
private viewCells: QueryList<A11yCellDirective>;
|
||||
|
||||
@ContentChildren(A11yCellDirective)
|
||||
private contentCells: QueryList<A11yCellDirective>;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.cells = [...this.viewCells, ...this.contentCells];
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
|
||||
const styles: Record<BadgeVariant, string[]> = {
|
||||
|
@ -22,8 +24,9 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
|
|||
|
||||
@Directive({
|
||||
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
|
||||
providers: [{ provide: FocusableElement, useExisting: BadgeDirective }],
|
||||
})
|
||||
export class BadgeDirective {
|
||||
export class BadgeDirective implements FocusableElement {
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
"tw-inline-block",
|
||||
|
@ -62,6 +65,10 @@ export class BadgeDirective {
|
|||
*/
|
||||
@Input() truncate = true;
|
||||
|
||||
getFocusTarget() {
|
||||
return this.el.nativeElement;
|
||||
}
|
||||
|
||||
private hasHoverEffects = false;
|
||||
|
||||
constructor(private el: ElementRef<HTMLElement>) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
|
||||
|
||||
|
@ -123,9 +124,12 @@ const sizes: Record<IconButtonSize, string[]> = {
|
|||
@Component({
|
||||
selector: "button[bitIconButton]:not(button[bitButton])",
|
||||
templateUrl: "icon-button.component.html",
|
||||
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
|
||||
providers: [
|
||||
{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent },
|
||||
{ provide: FocusableElement, useExisting: BitIconButtonComponent },
|
||||
],
|
||||
})
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction {
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
|
||||
@Input("bitIconButton") icon: string;
|
||||
|
||||
@Input() buttonType: IconButtonType;
|
||||
|
@ -162,4 +166,10 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction {
|
|||
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
|
||||
this.buttonType = value;
|
||||
}
|
||||
|
||||
getFocusTarget() {
|
||||
return this.elementRef.nativeElement;
|
||||
}
|
||||
|
||||
constructor(private elementRef: ElementRef) {}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,7 @@ import { take } from "rxjs/operators";
|
|||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
/**
|
||||
* Interface for implementing focusable components. Used by the AutofocusDirective.
|
||||
*/
|
||||
export abstract class FocusableElement {
|
||||
focus: () => void;
|
||||
}
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
/**
|
||||
* Directive to focus an element.
|
||||
|
@ -46,7 +41,7 @@ export class AutofocusDirective {
|
|||
|
||||
private focus() {
|
||||
if (this.focusableElement) {
|
||||
this.focusableElement.focus();
|
||||
this.focusableElement.getFocusTarget().focus();
|
||||
} else {
|
||||
this.el.nativeElement.focus();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { A11yCellDirective } from "../a11y/a11y-cell.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-item-action",
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `<ng-content></ng-content>`,
|
||||
providers: [{ provide: A11yCellDirective, useExisting: ItemActionComponent }],
|
||||
})
|
||||
export class ItemActionComponent extends A11yCellDirective {}
|
|
@ -0,0 +1,32 @@
|
|||
<!-- TODO: Colors will be finalized in the extension refresh feature branch -->
|
||||
<div
|
||||
class="tw-box-border tw-overflow-auto tw-flex tw-bg-background [&:hover:not(:has(.end-slot:hover))]:tw-bg-primary-300/20 tw-text-main tw-border-solid tw-border-b tw-border-0 tw-rounded-lg tw-mb-1.5"
|
||||
[ngClass]="
|
||||
focusVisibleWithin()
|
||||
? 'tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-primary-600 tw-border-transparent'
|
||||
: 'tw-border-b-secondary-300 [&:hover:not(:has(.end-slot:hover))]:tw-border-b-transparent'
|
||||
"
|
||||
>
|
||||
<!-- TODO render as anchor -->
|
||||
<bit-item-action class="tw-block tw-w-full">
|
||||
<button
|
||||
bitFocusableElement
|
||||
type="button"
|
||||
class="fvw-target tw-outline-none tw-text-main tw-text-base tw-p-4 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between"
|
||||
>
|
||||
<div class="tw-flex tw-gap-2 tw-items-center">
|
||||
<i *ngIf="iconStart" class="bwi tw-text-[1.75rem] tw-text-muted" [ngClass]="iconStart"></i>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
<i *ngIf="iconEnd" class="bwi tw-text-2xl tw-text-main" [ngClass]="iconEnd"></i>
|
||||
</button>
|
||||
</bit-item-action>
|
||||
|
||||
<div
|
||||
#endSlot
|
||||
class="end-slot tw-p-2 tw-flex tw-gap-2 tw-items-center"
|
||||
[hidden]="endSlot.childElementCount === 0"
|
||||
>
|
||||
<ng-content select="bit-item-action"></ng-content>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,48 @@
|
|||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
Output,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
|
||||
import { A11yRowDirective } from "../a11y/a11y-row.directive";
|
||||
import { FocusableElementDirective } from "../shared/focusable-element";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
import { ItemActionComponent } from "./item-action.component";
|
||||
|
||||
@Component({
|
||||
selector: "bit-item",
|
||||
standalone: true,
|
||||
imports: [CommonModule, TypographyModule, ItemActionComponent, FocusableElementDirective],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "item.component.html",
|
||||
providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }],
|
||||
})
|
||||
export class ItemComponent extends A11yRowDirective {
|
||||
@Input()
|
||||
iconStart: string | null = null;
|
||||
|
||||
@Input()
|
||||
iconEnd: string | null = null;
|
||||
|
||||
@Output()
|
||||
mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
|
||||
|
||||
/**
|
||||
* We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
|
||||
*/
|
||||
protected focusVisibleWithin = signal(false);
|
||||
@HostListener("focusin", ["$event.target"])
|
||||
onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin.set(target.matches(".fvw-target:focus-visible"));
|
||||
}
|
||||
@HostListener("focusout")
|
||||
onFocusOut() {
|
||||
this.focusVisibleWithin.set(false);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { A11yGridDirective } from "../a11y/a11y-grid.directive";
|
||||
|
||||
@Component({
|
||||
selector: "bit-list",
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `<ng-content></ng-content>`,
|
||||
})
|
||||
export class ListComponent extends A11yGridDirective {}
|
|
@ -0,0 +1,17 @@
|
|||
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./list.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
# List
|
||||
|
||||
The `BitListComponent` is a container that displays one or more instances of `BitItemComponent`.
|
||||
|
||||
## Icons
|
||||
|
||||
## Primary Action
|
||||
|
||||
## Secondary Actions
|
||||
|
||||
## A11y
|
|
@ -0,0 +1,206 @@
|
|||
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { AvatarModule } from "../avatar";
|
||||
import { BadgeModule } from "../badge";
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
import { ItemActionComponent } from "./item-action.component";
|
||||
import { ItemComponent } from "./item.component";
|
||||
import { ListComponent } from "./list.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/List",
|
||||
component: ListComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
ItemComponent,
|
||||
AvatarModule,
|
||||
IconButtonModule,
|
||||
BadgeModule,
|
||||
TypographyModule,
|
||||
ItemActionComponent,
|
||||
],
|
||||
}),
|
||||
componentWrapperDecorator((story) => `<div class="tw-bg-background-alt tw-p-2">${story}</div>`),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<ListComponent>;
|
||||
|
||||
export const StandaloneItem: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const CustomContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-item iconEnd="bwi-lock">
|
||||
<bit-avatar slot="start" size="small" text="Baz"></bit-avatar>
|
||||
<div class="tw-flex tw-flex-col tw-items-start">
|
||||
<span>baz@bitwarden.com</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">bitwarden.com</span>
|
||||
<span bitTypography="helper" class="tw-text-muted"><em>locked</em></span>
|
||||
</div>
|
||||
</bit-item>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const SingleActionList: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-list>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
<bit-item iconEnd="bwi-angle-right">
|
||||
Foo
|
||||
</bit-item>
|
||||
</bit-list>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const MultiActionList: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-list>
|
||||
<bit-item iconStart="bwi-globe">
|
||||
Bar
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</bit-item>
|
||||
<bit-item iconStart="bwi-globe">
|
||||
Bar
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</bit-item>
|
||||
<bit-item iconStart="bwi-globe">
|
||||
Bar
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</bit-item>
|
||||
<bit-item iconStart="bwi-globe">
|
||||
Bar
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</bit-item>
|
||||
<bit-item iconStart="bwi-globe">
|
||||
Bar
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</bit-item>
|
||||
<bit-item iconStart="bwi-globe">
|
||||
Bar
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</bit-item>
|
||||
<bit-item iconStart="bwi-globe">
|
||||
Bar
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</bit-item>
|
||||
<bit-item iconStart="bwi-globe">
|
||||
Bar
|
||||
<bit-item-action>
|
||||
<button type="button" bitBadge variant="primary">Auto-fill</button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-clone"></button>
|
||||
</bit-item-action>
|
||||
<bit-item-action>
|
||||
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
|
||||
</bit-item-action>
|
||||
</bit-item>
|
||||
</bit-list>
|
||||
`,
|
||||
}),
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, ElementRef, Input, ViewChild } from "@angular/core";
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
|
||||
import { FocusableElement } from "../input/autofocus.directive";
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
|
@ -32,8 +32,8 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
|
|||
@Input() disabled: boolean;
|
||||
@Input() placeholder: string;
|
||||
|
||||
focus() {
|
||||
this.input.nativeElement.focus();
|
||||
getFocusTarget() {
|
||||
return this.input.nativeElement;
|
||||
}
|
||||
|
||||
onChange(searchText: string) {
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { Directive, ElementRef } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Interface for implementing focusable components. Used by the AutofocusDirective.
|
||||
*/
|
||||
export abstract class FocusableElement {
|
||||
getFocusTarget: () => HTMLElement;
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: "[bitFocusableElement]",
|
||||
standalone: true,
|
||||
providers: [{ provide: FocusableElement, useExisting: FocusableElementDirective }],
|
||||
})
|
||||
export class FocusableElementDirective implements FocusableElement {
|
||||
constructor(private elementRef: ElementRef) {}
|
||||
|
||||
getFocusTarget() {
|
||||
return this.elementRef.nativeElement;
|
||||
}
|
||||
}
|
|
@ -120,7 +120,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "7.4.0",
|
||||
"@typescript-eslint/parser": "7.4.0",
|
||||
"@webcomponents/custom-elements": "1.6.0",
|
||||
"autoprefixer": "10.4.18",
|
||||
"autoprefixer": "10.4.19",
|
||||
"base64-loader": "1.0.0",
|
||||
"chromatic": "10.9.6",
|
||||
"concurrently": "8.2.2",
|
||||
|
@ -12930,9 +12930,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.18",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz",
|
||||
"integrity": "sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==",
|
||||
"version": "10.4.19",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
|
||||
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -12950,7 +12950,7 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"browserslist": "^4.23.0",
|
||||
"caniuse-lite": "^1.0.30001591",
|
||||
"caniuse-lite": "^1.0.30001599",
|
||||
"fraction.js": "^4.3.7",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.0.0",
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "7.4.0",
|
||||
"@typescript-eslint/parser": "7.4.0",
|
||||
"@webcomponents/custom-elements": "1.6.0",
|
||||
"autoprefixer": "10.4.18",
|
||||
"autoprefixer": "10.4.19",
|
||||
"base64-loader": "1.0.0",
|
||||
"chromatic": "10.9.6",
|
||||
"concurrently": "8.2.2",
|
||||
|
|
Loading…
Reference in New Issue