diff --git a/packages/host/app/components/file-chooser/mini/index.gts b/packages/host/app/components/file-chooser/mini/index.gts new file mode 100644 index 00000000000..bf521115457 --- /dev/null +++ b/packages/host/app/components/file-chooser/mini/index.gts @@ -0,0 +1,300 @@ +import { array, fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { BoxelButton, LoadingIndicator } from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; + +import { RealmPaths, type LocalPath } from '@cardstack/runtime-common'; + +import type { FileDef } from 'https://cardstack.com/base/file-api'; + +import FileChooser, { type FileChooserRealm } from '../panel'; + +interface Signature { + Element: HTMLDivElement; + Args: { + // Fired with the absolute URL of the file the user picks from the tree or + // finishes uploading. The hosting container decides what to do with it + // (this primitive never confirms or dismisses on its own). + onSelect: (url: string) => void; + // Workspace to open on first render. Read once at mount; later parent + // updates are ignored. Defaults to the first known realm. + initialRealmURL?: string; + // Absolute URL of the currently selected file — the matching tree row gets + // the selection highlight. Omit for a chooser with no pinned selection. + selected?: string; + }; +} + +export default class MiniFileChooser extends Component { + // The user's most recent in-tree pick, relative to the selected realm. Seeds + // the tree's highlight and takes precedence over @selected once the user acts. + @tracked private userSelectedFile?: LocalPath; + + // Highlighted tree row: the user's own pick wins; otherwise derive a local + // path from @selected when it lives inside the open workspace. + private selectedFileFor = ( + selectedRealm: FileChooserRealm | undefined, + ): LocalPath | undefined => { + if (this.userSelectedFile) { + return this.userSelectedFile; + } + let { selected } = this.args; + if (!selected || !selectedRealm) { + return undefined; + } + let paths = new RealmPaths(selectedRealm.url); + try { + let url = new URL(selected); + if (paths.inRealm(url)) { + return paths.local(url); + } + } catch { + // malformed URL or outside the realm — nothing to highlight + } + return undefined; + }; + + @action + private handleRealmChange() { + this.userSelectedFile = undefined; + } + + @action + private handleFileSelectOnly( + realm: FileChooserRealm | undefined, + path: LocalPath, + ) { + if (!realm) { + return; + } + this.userSelectedFile = path; + // Deliberately does NOT call this.args.onSelect — that only happens on + // confirmation (Enter) to avoid double-fire. + } + + @action + private handleFileSelected( + realm: FileChooserRealm | undefined, + path: LocalPath, + ) { + if (!realm) { + return; + } + this.userSelectedFile = path; + let url = new RealmPaths(realm.url).fileURL(path); + this.args.onSelect(url.href); + } + + @action + private handleUploadComplete(fileDef: FileDef) { + if (fileDef.sourceUrl) { + this.userSelectedFile = undefined; + this.args.onSelect(fileDef.sourceUrl); + } + } + + +} diff --git a/packages/host/app/components/file-chooser/mini/usage.gts b/packages/host/app/components/file-chooser/mini/usage.gts new file mode 100644 index 00000000000..f3c9c523878 --- /dev/null +++ b/packages/host/app/components/file-chooser/mini/usage.gts @@ -0,0 +1,73 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import MiniFileChooser from './index'; + +export default class MiniFileChooserUsage extends Component { + @tracked selectedUrl: string | undefined; + + @action onSelect(url: string) { + this.selectedUrl = url; + } + + +} diff --git a/packages/host/app/components/file-chooser/modal.gts b/packages/host/app/components/file-chooser/modal.gts new file mode 100644 index 00000000000..c38be63ced0 --- /dev/null +++ b/packages/host/app/components/file-chooser/modal.gts @@ -0,0 +1,480 @@ +import { registerDestructor } from '@ember/destroyable'; +import { array, fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import type Owner from '@ember/owner'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; + +import { tracked } from '@glimmer/tracking'; + +import { task } from 'ember-concurrency'; +import perform from 'ember-concurrency/helpers/perform'; +import onKeyMod from 'ember-keyboard/modifiers/on-key'; + +import { + BoxelButton, + FieldContainer, + LoadingIndicator, +} from '@cardstack/boxel-ui/components'; + +import { eq } from '@cardstack/boxel-ui/helpers'; + +import { + Deferred, + RealmPaths, + isCardErrorJSONAPI, + loadCardDef, + type CodeRef, + type LocalPath, +} from '@cardstack/runtime-common'; + +import ModalContainer from '@cardstack/host/components/modal-container'; + +import type LoaderService from '@cardstack/host/services/loader-service'; +import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; +import type StoreService from '@cardstack/host/services/store'; + +import type { FileDef } from 'https://cardstack.com/base/file-api'; + +import FileChooser, { type FileChooserRealm } from './panel'; + +interface Signature { + Args: {}; +} + +export default class FileChooserModal extends Component { + @tracked deferred?: Deferred; + @tracked selectedFile?: LocalPath; + @tracked fileTypeFilter?: CodeRef; + @tracked fileFieldFilter?: Record; + @tracked fileTypeName?: string; + @tracked acceptTypes?: string; + @tracked initialRealmURL?: string; + + @service declare private operatorModeStateService: OperatorModeStateService; + @service declare private store: StoreService; + @service('loader-service') declare private loaderService: LoaderService; + + constructor(owner: Owner, args: Signature['Args']) { + super(owner, args); + (globalThis as any)._CARDSTACK_FILE_CHOOSER = this; + registerDestructor(this, () => { + delete (globalThis as any)._CARDSTACK_FILE_CHOOSER; + }); + } + + private get modalTitle(): string { + if (this.fileTypeName) { + return `Choose ${this.fileTypeName}`; + } + return 'Choose a File'; + } + + // public API + async chooseFile(opts?: { + fileType?: CodeRef; + fileTypeName?: string; + fileFieldFilter?: Record; + }): Promise { + this.deferred = new Deferred(); + this.fileTypeFilter = opts?.fileType; + this.fileFieldFilter = opts?.fileFieldFilter; + this.fileTypeName = opts?.fileTypeName; + this.acceptTypes = undefined; + this.selectedFile = undefined; + this.initialRealmURL = this.operatorModeStateService.realmURL?.toString(); + + if (opts?.fileType) { + try { + let cardDef = await loadCardDef(opts.fileType, { + loader: this.loaderService.loader, + }); + this.acceptTypes = (cardDef as any).acceptTypes; + } catch { + // If we can't load the def, acceptTypes stays undefined (allow all) + } + } + + let file = await this.deferred.promise; + if (file) { + return file as T; + } else { + return undefined; + } + } + + private pickTask = task( + async ( + selectedRealm: FileChooserRealm | undefined, + path: LocalPath | undefined, + ) => { + let deferred = this.deferred; + try { + if (deferred && selectedRealm && path) { + let fileURL = new RealmPaths(selectedRealm.url).fileURL(path); + let file = await this.store.get(fileURL.href, { + type: 'file-meta', + }); + if (isCardErrorJSONAPI(file)) { + deferred.reject( + new Error( + `file-chooser/modal: failed to load file meta for ${fileURL.href}`, + ), + ); + return; + } + deferred.fulfill(file); + } else { + // Cancel / Escape / close with no selection: settle the promise with + // undefined so callers awaiting chooseFile() resume. Otherwise the + // deferred is dropped unsettled by resetState() and the await hangs + // forever — leaving a trigger button stuck disabled/loading. + deferred?.fulfill(undefined); + } + } finally { + this.resetState(); + } + }, + ); + + @action + private handleFileSelected(path: LocalPath) { + this.selectedFile = path; + } + + @action + private handleRealmChange() { + // Stage cleared on workspace switch — the previous pick lived in a + // different realm. + this.selectedFile = undefined; + } + + @action + private handleUploadComplete(fileDef: FileDef) { + if (this.deferred) { + this.deferred.fulfill(fileDef); + this.resetState(); + } + } + + private resetState() { + this.selectedFile = undefined; + this.fileTypeFilter = undefined; + this.fileFieldFilter = undefined; + this.fileTypeName = undefined; + this.acceptTypes = undefined; + this.initialRealmURL = undefined; + this.deferred = undefined; + } + + @action private handleKeydown(event: Event) { + let kbEvent = event as KeyboardEvent; + if (kbEvent.key === 'Escape') { + this.pickTask.perform(undefined, undefined); + return; + } + if (kbEvent.key === 'Tab') { + this.trapFocus(kbEvent); + } + } + + private trapFocus(event: KeyboardEvent) { + const container = event.currentTarget as HTMLElement; + const focusableSelector = [ + 'button:not([disabled]):not([tabindex="-1"])', + '[tabindex="0"]', + 'input:not([disabled])', + 'select:not([disabled])', + 'a[href]', + ].join(', '); + const focusable = Array.from( + container.querySelectorAll(focusableSelector), + ); + if (focusable.length < 2) return; + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + + if (event.shiftKey) { + if (document.activeElement === first) { + event.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + } + + +} diff --git a/packages/host/app/components/file-chooser/panel.gts b/packages/host/app/components/file-chooser/panel.gts new file mode 100644 index 00000000000..0602bcf9995 --- /dev/null +++ b/packages/host/app/components/file-chooser/panel.gts @@ -0,0 +1,270 @@ +import { hash } from '@ember/helper'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import type { CodeRef, LocalPath } from '@cardstack/runtime-common'; + +import type FileUploadService from '@cardstack/host/services/file-upload'; +import type { FileUploadTask } from '@cardstack/host/services/file-upload'; +import type RealmService from '@cardstack/host/services/realm'; +import type { EnhancedRealmInfo } from '@cardstack/host/services/realm'; + +import type { FileDef } from 'https://cardstack.com/base/file-api'; + +import IndexedFileTree from '../editor/indexed-file-tree'; +import RealmDropdown, { type RealmDropdownItem } from '../realm-dropdown'; + +import type { WithBoundArgs } from '@glint/template'; + +export type FileChooserRealm = { url: URL; info: EnhancedRealmInfo }; + +interface Signature { + Args: { + initialRealmURL?: string; + selectedFile?: LocalPath; + fileTypeFilter?: CodeRef; + fileFieldFilter?: Record; + acceptTypes?: string; + onRealmChange?: (realm: FileChooserRealm) => void; + onFileSelected?: (path: LocalPath) => void; + onFileConfirmed?: (path: LocalPath) => void; + onUploadComplete: (file: FileDef) => void; + }; + Blocks: { + default: [ + { + RealmDropdown: WithBoundArgs< + typeof RealmDropdown, + 'selectedRealmURL' | 'onSelect' | 'displayReadOnlyTag' + >; + FileTree: WithBoundArgs< + typeof IndexedFileTree, + | 'selectedFile' + | 'fileTypeFilter' + | 'fileFieldFilter' + | 'onFileSelected' + | 'onFileConfirmed' + >; + fileTreeKey: string; + selectedRealm: FileChooserRealm | undefined; + selectedRealmURL: string | undefined; + currentUpload: FileUploadTask | undefined; + isUploadBusy: boolean; + triggerUpload: () => void; + dropZoneActive: boolean; + dropZoneLabel: string; + onDragEnter: (event: Event) => void; + onDragOver: (event: Event) => void; + onDragLeave: (event: Event) => void; + onDrop: (event: Event) => void; + }, + ]; + }; +} + +export default class FileChooser extends Component { + @service declare private realm: RealmService; + @service('file-upload') declare private fileUpload: FileUploadService; + + @tracked private _explicitlySelectedRealm?: FileChooserRealm; + + private get selectedRealm(): FileChooserRealm | undefined { + return this._explicitlySelectedRealm ?? this.initialRealm; + } + + private set selectedRealm(value: FileChooserRealm | undefined) { + this._explicitlySelectedRealm = value; + } + @tracked private currentUpload?: FileUploadTask; + @tracked private isDropZoneActive = false; + // Bumped on realm switch so the yielded FileTree (which keys off realm URL + // internally) is fully recreated rather than reused across workspaces. + @tracked private fileTreeRenderNonce = 0; + private dropZoneDragDepth = 0; + + private get knownRealms(): FileChooserRealm[] { + return Object.entries(this.realm.allRealmsInfo).map((entry) => ({ + url: new URL(entry[0]), + info: entry[1].info, + })); + } + + private get initialRealm(): FileChooserRealm | undefined { + let realms = this.knownRealms; + let match = this.args.initialRealmURL + ? realms.find((r) => r.url.href === this.args.initialRealmURL) + : undefined; + return match ?? realms[0]; + } + + private get selectedRealmURL(): string | undefined { + return this.selectedRealm?.url.href; + } + + private get fileTreeKey(): string { + return `${this.fileTreeRenderNonce}:${this.selectedRealm?.url.href ?? ''}`; + } + + private get isUploadBusy(): boolean { + let state = this.currentUpload?.state; + return state === 'picking' || state === 'uploading'; + } + + private get dropZoneLabel(): string { + if (!this.selectedRealm) { + return ''; + } + return `Drop file to upload to ${this.selectedRealm.info.name}`; + } + + @action + private selectRealm({ path }: RealmDropdownItem) { + let realm = this.knownRealms.find((r) => r.url.href === path); + if (!realm) { + return; + } + this.selectedRealm = realm; + this.fileTreeRenderNonce++; + this.args.onRealmChange?.(realm); + } + + @action + private handleFileSelected(path: LocalPath) { + this.args.onFileSelected?.(path); + } + + @action + private handleFileConfirmed(path: LocalPath) { + this.args.onFileConfirmed?.(path); + } + + @action + private triggerUpload() { + if (!this.selectedRealm) { + return; + } + let task = this.fileUpload.uploadFile({ + realmURL: this.selectedRealm.url, + acceptTypes: this.args.acceptTypes, + }); + this.beginUpload(task); + } + + @action + private handleDragEnter(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth++; + this.isDropZoneActive = true; + } + + @action + private handleDragOver(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.isDropZoneActive = true; + if (dragEvent.dataTransfer) { + dragEvent.dataTransfer.dropEffect = 'copy'; + } + } + + @action + private handleDragLeave(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer) && !this.isDropZoneActive) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth = Math.max(0, this.dropZoneDragDepth - 1); + if (this.dropZoneDragDepth === 0) { + this.isDropZoneActive = false; + } + } + + @action + private handleDrop(event: Event) { + let dragEvent = event as DragEvent; + if (!this.isFileDrag(dragEvent.dataTransfer)) { + return; + } + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + this.dropZoneDragDepth = 0; + this.isDropZoneActive = false; + if (this.isUploadBusy || !this.selectedRealm) { + return; + } + let file = dragEvent.dataTransfer?.files?.[0]; + if (!file) { + return; + } + let task = this.fileUpload.uploadProvidedFile({ + realmURL: this.selectedRealm.url, + file, + }); + this.beginUpload(task); + } + + private beginUpload(task: FileUploadTask) { + this.currentUpload = task; + task.result.then((fileDef) => { + if (fileDef) { + this.currentUpload = undefined; + this.args.onUploadComplete(fileDef); + } else if (task.state !== 'error') { + this.currentUpload = undefined; + } + }); + } + + private isFileDrag(dataTransfer: DataTransfer | null | undefined): boolean { + if (!dataTransfer) { + return false; + } + return Array.from(dataTransfer.types ?? []).includes('Files'); + } + + +} diff --git a/packages/host/app/components/operator-mode/choose-file-modal.gts b/packages/host/app/components/operator-mode/choose-file-modal.gts deleted file mode 100644 index d328c7f8044..00000000000 --- a/packages/host/app/components/operator-mode/choose-file-modal.gts +++ /dev/null @@ -1,574 +0,0 @@ -import { registerDestructor } from '@ember/destroyable'; -import { array, fn } from '@ember/helper'; -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import type Owner from '@ember/owner'; -import { service } from '@ember/service'; -import Component from '@glimmer/component'; - -import { tracked } from '@glimmer/tracking'; - -import { task } from 'ember-concurrency'; -import perform from 'ember-concurrency/helpers/perform'; -import onKeyMod from 'ember-keyboard/modifiers/on-key'; - -import { - BoxelButton, - FieldContainer, - LoadingIndicator, -} from '@cardstack/boxel-ui/components'; - -import { eq } from '@cardstack/boxel-ui/helpers'; - -import { - Deferred, - RealmPaths, - isCardErrorJSONAPI, - loadCardDef, - type CodeRef, - type LocalPath, -} from '@cardstack/runtime-common'; - -import ModalContainer from '@cardstack/host/components/modal-container'; - -import type FileUploadService from '@cardstack/host/services/file-upload'; -import type { FileUploadTask } from '@cardstack/host/services/file-upload'; -import type LoaderService from '@cardstack/host/services/loader-service'; -import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; -import type RealmService from '@cardstack/host/services/realm'; -import type StoreService from '@cardstack/host/services/store'; - -import type { FileDef } from 'https://cardstack.com/base/file-api'; - -import IndexedFileTree from '../editor/indexed-file-tree'; -import RealmDropdown, { type RealmDropdownItem } from '../realm-dropdown'; - -interface Signature { - Args: {}; -} - -export default class ChooseFileModal extends Component { - @tracked deferred?: Deferred; - @tracked selectedRealm = this.knownRealms[0]; - @tracked selectedFile?: LocalPath; - @tracked fileTypeFilter?: CodeRef; - @tracked fileFieldFilter?: Record; - @tracked fileTypeName?: string; - @tracked acceptTypes?: string; - @tracked currentUpload?: FileUploadTask; - @tracked isDropZoneActive = false; - @tracked private fileTreeRenderNonce = 0; - private dropZoneDragDepth = 0; - - @service declare private operatorModeStateService: OperatorModeStateService; - @service declare private realm: RealmService; - @service declare private store: StoreService; - @service('file-upload') declare private fileUpload: FileUploadService; - @service('loader-service') declare private loaderService: LoaderService; - - constructor(owner: Owner, args: Signature['Args']) { - super(owner, args); - (globalThis as any)._CARDSTACK_FILE_CHOOSER = this; - registerDestructor(this, () => { - delete (globalThis as any)._CARDSTACK_FILE_CHOOSER; - }); - } - - private get modalTitle(): string { - if (this.fileTypeName) { - return `Choose ${this.fileTypeName}`; - } - return 'Choose a File'; - } - - private get isUploadBusy(): boolean { - let state = this.currentUpload?.state; - return state === 'picking' || state === 'uploading'; - } - - // public API - async chooseFile(opts?: { - fileType?: CodeRef; - fileTypeName?: string; - fileFieldFilter?: Record; - }): Promise { - this.deferred = new Deferred(); - this.fileTypeFilter = opts?.fileType; - this.fileFieldFilter = opts?.fileFieldFilter; - this.fileTypeName = opts?.fileTypeName; - this.acceptTypes = undefined; - this.currentUpload = undefined; - let defaultRealm = this.knownRealms.find( - (r) => - r.url.toString() === this.operatorModeStateService.realmURL?.toString(), - ); - this.selectedRealm = defaultRealm ?? this.selectedRealm; - this.fileTreeRenderNonce++; - - if (opts?.fileType) { - try { - let cardDef = await loadCardDef(opts.fileType, { - loader: this.loaderService.loader, - }); - this.acceptTypes = (cardDef as any).acceptTypes; - } catch { - // If we can't load the def, acceptTypes stays undefined (allow all) - } - } - - let file = await this.deferred.promise; - if (file) { - return file as T; - } else { - return undefined; - } - } - - private pickTask = task(async (path: LocalPath | undefined) => { - let deferred = this.deferred; - try { - if (deferred && this.selectedRealm && path) { - let fileURL = new RealmPaths(this.selectedRealm.url).fileURL(path); - let file = await this.store.get(fileURL.href, { - type: 'file-meta', - }); - if (isCardErrorJSONAPI(file)) { - deferred.reject( - new Error( - `choose-file-modal: failed to load file meta for ${fileURL.href}`, - ), - ); - return; - } - deferred.fulfill(file); - } else { - // Cancel / Escape / close with no selection: settle the promise with - // undefined so callers awaiting chooseFile() resume. Otherwise the - // deferred is dropped unsettled by resetState() and the await hangs - // forever — leaving a trigger button stuck disabled/loading. - deferred?.fulfill(undefined); - } - } finally { - this.resetState(); - } - }); - - @action - private triggerUpload() { - let task = this.fileUpload.uploadFile({ - realmURL: this.selectedRealm.url, - acceptTypes: this.acceptTypes, - }); - this.beginUpload(task); - } - - @action - private handleDragEnter(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth++; - this.isDropZoneActive = true; - } - - @action - private handleDragOver(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.isDropZoneActive = true; - if (dragEvent.dataTransfer) { - dragEvent.dataTransfer.dropEffect = 'copy'; - } - } - - @action - private handleDragLeave(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer) && !this.isDropZoneActive) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth = Math.max(0, this.dropZoneDragDepth - 1); - if (this.dropZoneDragDepth === 0) { - this.isDropZoneActive = false; - } - } - - @action - private handleDrop(event: Event) { - let dragEvent = event as DragEvent; - if (!this.isFileDrag(dragEvent.dataTransfer)) { - return; - } - dragEvent.preventDefault(); - dragEvent.stopPropagation(); - this.dropZoneDragDepth = 0; - this.isDropZoneActive = false; - if (this.isUploadBusy) { - return; - } - let file = dragEvent.dataTransfer?.files?.[0]; - if (!file) { - return; - } - let task = this.fileUpload.uploadProvidedFile({ - realmURL: this.selectedRealm.url, - file, - }); - this.beginUpload(task); - } - - private beginUpload(task: FileUploadTask) { - this.currentUpload = task; - task.result.then((fileDef) => { - if (fileDef && this.deferred) { - this.deferred.fulfill(fileDef); - this.resetState(); - } else if (task.state !== 'error') { - this.currentUpload = undefined; - } - }); - } - - private isFileDrag(dataTransfer: DataTransfer | null | undefined): boolean { - if (!dataTransfer) { - return false; - } - return Array.from(dataTransfer.types ?? []).includes('Files'); - } - - private get dropZoneLabel() { - return `Drop file to upload to ${this.selectedRealm.info.name}`; - } - - private get fileTreeRenderKey(): string { - return `${this.fileTreeRenderNonce}:${this.selectedRealm.url.href}`; - } - - private resetState() { - this.selectedRealm = this.knownRealms[0]; - this.selectedFile = undefined; - this.fileTypeFilter = undefined; - this.fileFieldFilter = undefined; - this.fileTypeName = undefined; - this.acceptTypes = undefined; - this.currentUpload = undefined; - this.isDropZoneActive = false; - this.dropZoneDragDepth = 0; - this.deferred = undefined; - } - - private get knownRealms() { - return Object.entries(this.realm.allRealmsInfo).map((entry) => ({ - url: new URL(entry[0]), - info: entry[1].info, - })); - } - - @action - private selectRealm({ path }: RealmDropdownItem) { - let realm = this.knownRealms.find((r) => r.url.href === path); - if (realm) { - this.selectedRealm = realm; - } - } - - @action - private selectFile(file: LocalPath) { - this.selectedFile = file; - } - - @action private handleKeydown(event: Event) { - let kbEvent = event as KeyboardEvent; - if (kbEvent.key === 'Escape') { - this.pickTask.perform(undefined); - return; - } - if (kbEvent.key === 'Tab') { - this.trapFocus(kbEvent); - } - } - - private trapFocus(event: KeyboardEvent) { - const container = event.currentTarget as HTMLElement; - const focusableSelector = [ - 'button:not([disabled]):not([tabindex="-1"])', - '[tabindex="0"]', - 'input:not([disabled])', - 'select:not([disabled])', - 'a[href]', - ].join(', '); - const focusable = Array.from( - container.querySelectorAll(focusableSelector), - ); - if (focusable.length < 2) return; - const first = focusable[0]!; - const last = focusable[focusable.length - 1]!; - - if (event.shiftKey) { - if (document.activeElement === first) { - event.preventDefault(); - last.focus(); - } - } else { - if (document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - } - } - - -} diff --git a/packages/host/app/components/operator-mode/container.gts b/packages/host/app/components/operator-mode/container.gts index 67aa2d72a52..5ca68af2f03 100644 --- a/packages/host/app/components/operator-mode/container.gts +++ b/packages/host/app/components/operator-mode/container.gts @@ -38,10 +38,10 @@ import type { CardContext } from 'https://cardstack.com/base/card-api'; import CardChooserModal from '../card-chooser/modal'; import SearchResults from '../card-search/search-results'; +import FileChooserModal from '../file-chooser/modal'; import PrerenderedCardSearch from '../prerendered-card-search'; import { Submodes } from '../submode-switcher'; -import ChooseFileModal from './choose-file-modal'; import CreateListingModal from './create-listing-modal'; import type CardService from '../../services/card-service'; @@ -146,7 +146,7 @@ export default class OperatorModeContainer extends Component {