-
Notifications
You must be signed in to change notification settings - Fork 12
Add MiniFileChooser component (CS-11680) #5298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
FadhlanR
wants to merge
8
commits into
main
Choose a base branch
from
cs-11680-mini-file-chooser
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
3404164
Add MiniFileChooser component (CS-11680)
FadhlanR 860977b
Fix MiniFileChooser: empty-realm guard and drop-zone attribute selector
FadhlanR b7d93bb
Extract shared FileChooser panel; migrate choose-file modal onto it
FadhlanR cfa552c
Fix CI: RealmDropdown empty-realm guard and drop-zone attribute asser…
FadhlanR 3b20815
Fix MiniFileChooser: show tree focus ring only on keyboard focus
FadhlanR 20f6f99
fix: address PR review feedback — realm async init, double-fire on En…
FadhlanR b8226b9
fix test: click then Enter to select in mini-file-chooser test
FadhlanR fb177eb
Remove .hermes/pr-5298-feedback-plan.md
FadhlanR File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
300 changes: 300 additions & 0 deletions
300
packages/host/app/components/file-chooser/mini/index.gts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Signature> { | ||
| // 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); | ||
| } | ||
| } | ||
|
|
||
| <template> | ||
| <FileChooser | ||
| @initialRealmURL={{@initialRealmURL}} | ||
| @onRealmChange={{this.handleRealmChange}} | ||
| @onUploadComplete={{this.handleUploadComplete}} | ||
| as |chooser| | ||
| > | ||
| <div | ||
| class='mini-file-chooser' | ||
| data-test-mini-file-chooser | ||
| data-drop-zone-active={{chooser.dropZoneActive}} | ||
| data-drop-zone-label={{chooser.dropZoneLabel}} | ||
| {{on 'dragenter' chooser.onDragEnter}} | ||
| {{on 'dragover' chooser.onDragOver}} | ||
| {{on 'dragleave' chooser.onDragLeave}} | ||
| {{on 'drop' chooser.onDrop}} | ||
| ...attributes | ||
| > | ||
| <div class='mini-file-chooser__field'> | ||
| <span class='mini-file-chooser__label'>Workspace</span> | ||
| <chooser.RealmDropdown | ||
| class='mini-file-chooser__realm-chooser' | ||
| data-test-mini-file-chooser-realm-chooser | ||
| /> | ||
| </div> | ||
|
|
||
| <div class='mini-file-chooser__field mini-file-chooser__tree-field'> | ||
| <span class='mini-file-chooser__label'>Choose File</span> | ||
| <div class='mini-file-chooser__tree'> | ||
| {{#if chooser.selectedRealm}} | ||
| {{! Force recreation when the realm changes }} | ||
| {{#each (array chooser.fileTreeKey)}} | ||
| <chooser.FileTree | ||
| @realmURL={{chooser.selectedRealm.url.href}} | ||
| @selectedFile={{this.selectedFileFor chooser.selectedRealm}} | ||
| @onFileSelected={{fn | ||
| this.handleFileSelectOnly | ||
| chooser.selectedRealm | ||
| }} | ||
| @onFileConfirmed={{fn | ||
| this.handleFileSelected | ||
| chooser.selectedRealm | ||
| }} | ||
| @autoFocus={{true}} | ||
|
Comment on lines
+139
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Fixed in 20f6f99. |
||
| /> | ||
| {{/each}} | ||
| {{/if}} | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class='mini-file-chooser__footer'> | ||
| {{#if (eq chooser.currentUpload.state 'picking')}} | ||
| <BoxelButton | ||
| @disabled={{true}} | ||
| data-test-mini-file-chooser-upload-button | ||
| > | ||
| Choose a file… | ||
| </BoxelButton> | ||
| {{else if (eq chooser.currentUpload.state 'uploading')}} | ||
| <div | ||
| class='mini-file-chooser__upload-progress' | ||
| data-test-mini-file-chooser-upload-progress | ||
| > | ||
| <span | ||
| class='mini-file-chooser__upload-name' | ||
| >{{chooser.currentUpload.fileName}}</span> | ||
| <LoadingIndicator class='mini-file-chooser__upload-spinner' /> | ||
| </div> | ||
| {{else if (eq chooser.currentUpload.state 'error')}} | ||
| <div class='mini-file-chooser__upload-error-row'> | ||
| <BoxelButton | ||
| {{on 'click' chooser.triggerUpload}} | ||
| data-test-mini-file-chooser-upload-button | ||
| > | ||
| Retry… | ||
| </BoxelButton> | ||
| <div | ||
| class='mini-file-chooser__upload-error' | ||
| data-test-mini-file-chooser-upload-error | ||
| >{{chooser.currentUpload.error}}</div> | ||
| </div> | ||
| {{else}} | ||
| <BoxelButton | ||
| {{on 'click' chooser.triggerUpload}} | ||
| data-test-mini-file-chooser-upload-button | ||
| > | ||
| Upload… | ||
| </BoxelButton> | ||
| {{/if}} | ||
| </div> | ||
| </div> | ||
| </FileChooser> | ||
|
|
||
| <style scoped> | ||
| .mini-file-chooser { | ||
| position: relative; | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: var(--boxel-sp-xs); | ||
| width: 100%; | ||
| height: 100%; | ||
| min-height: 0; | ||
| padding: var(--boxel-sp-xs); | ||
| background-color: var(--boxel-light); | ||
| } | ||
| .mini-file-chooser__field { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: var(--boxel-sp-xxxs); | ||
| flex: 0 0 auto; | ||
| } | ||
| .mini-file-chooser__tree-field { | ||
| flex: 1 1 auto; | ||
| min-height: 0; | ||
| } | ||
| .mini-file-chooser__label { | ||
| font: 600 var(--boxel-font-sm); | ||
| color: var(--boxel-dark); | ||
| } | ||
| .mini-file-chooser__realm-chooser { | ||
| width: 100%; | ||
| } | ||
| .mini-file-chooser__tree { | ||
| flex: 1 1 auto; | ||
| min-height: 0; | ||
| overflow: auto; | ||
| border: var(--boxel-border); | ||
| border-radius: var(--boxel-border-radius); | ||
| padding: var(--boxel-sp-xxs); | ||
| } | ||
| /* Ring on keyboard focus only — :focus-within would also fire on a | ||
| mouse click, drawing the ring around the whole tree when a file is | ||
| picked. */ | ||
| .mini-file-chooser__tree:has(:focus-visible) { | ||
| outline: 2px solid var(--ring, var(--boxel-highlight-hover)); | ||
| outline-offset: 2px; | ||
| } | ||
| .mini-file-chooser__tree :deep([data-file-tree-nav]:focus-visible) { | ||
| outline: none; | ||
| } | ||
| .mini-file-chooser__footer { | ||
| flex: 0 0 auto; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: var(--boxel-sp-xs); | ||
| min-width: 0; | ||
| } | ||
| .mini-file-chooser__upload-progress { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: var(--boxel-sp-xs); | ||
| min-width: 0; | ||
| } | ||
| .mini-file-chooser__upload-name { | ||
| font: var(--boxel-font-xs); | ||
| color: var(--boxel-600); | ||
| white-space: nowrap; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| max-width: 120px; | ||
| } | ||
| .mini-file-chooser__upload-spinner { | ||
| --boxel-loading-indicator-size: 1.25em; | ||
| } | ||
| .mini-file-chooser__upload-error-row { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: var(--boxel-sp-xs); | ||
| min-width: 0; | ||
| } | ||
| .mini-file-chooser__upload-error { | ||
| color: var(--boxel-error-200); | ||
| font: var(--boxel-font-xs); | ||
| overflow-wrap: anywhere; | ||
| } | ||
| /* Drag-and-drop overlay: dim the chooser and surface the drop label. */ | ||
| .mini-file-chooser[data-drop-zone-active]::before { | ||
| content: ''; | ||
| position: absolute; | ||
| inset: 0; | ||
| background-color: var(--boxel-darker-hover); | ||
| pointer-events: none; | ||
| z-index: 2; | ||
| } | ||
| .mini-file-chooser[data-drop-zone-active]::after { | ||
| content: attr(data-drop-zone-label); | ||
| position: absolute; | ||
| inset: 0; | ||
| padding: var(--boxel-sp-lg); | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| color: var(--boxel-light); | ||
| font: 600 var(--boxel-font); | ||
| text-align: center; | ||
| pointer-events: none; | ||
| z-index: 3; | ||
| } | ||
| </style> | ||
| </template> | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
|
|
||
| <template> | ||
| <FreestyleUsage @name='MiniFileChooser'> | ||
| <:description> | ||
| Compact, inline file picker for side-by-side layouts — the file-side | ||
| sibling of | ||
| <code>MiniCardChooser</code>. Wraps a workspace dropdown (<code | ||
| >RealmDropdown</code>) over the indexed file tree (<code | ||
| >IndexedFileTree</code>) in a fluid 100%-of-parent envelope, plus an | ||
| <code>Upload…</code> | ||
| button and drag-and-drop upload (reusing the | ||
| <code>file-upload</code> | ||
| service). The hosting container owns confirmation/dismissal — this | ||
| primitive only fires | ||
| <code>onSelect</code> | ||
| with the picked or uploaded file's absolute URL. | ||
| </:description> | ||
| <:example> | ||
| <div class='example-container'> | ||
| <MiniFileChooser @onSelect={{this.onSelect}} /> | ||
| </div> | ||
| {{#if this.selectedUrl}} | ||
| <p class='selection-readout' data-test-mini-file-chooser-selection> | ||
| Selected: | ||
| <code>{{this.selectedUrl}}</code> | ||
| </p> | ||
| {{/if}} | ||
| </:example> | ||
| <:api as |Args|> | ||
| <Args.Action | ||
| @name='onSelect' | ||
| @description='Called with the absolute URL of the picked or uploaded file.' | ||
| @required={{true}} | ||
| /> | ||
| <Args.String | ||
| @name='initialRealmURL' | ||
| @description='Optional workspace to open on first render. Read once at mount; defaults to the first known realm.' | ||
| /> | ||
| <Args.String | ||
| @name='selected' | ||
| @description='Absolute URL of the currently selected file. The matching tree row (when inside the open workspace) gets the selection highlight.' | ||
| /> | ||
| </:api> | ||
| </FreestyleUsage> | ||
| <style scoped> | ||
| .example-container { | ||
| width: 360px; | ||
| height: 480px; | ||
| border: 1px solid var(--boxel-border-color, var(--boxel-300)); | ||
| border-radius: var(--boxel-border-radius); | ||
| overflow: hidden; | ||
| } | ||
| .selection-readout { | ||
| margin-top: var(--boxel-sp-xs); | ||
| font: var(--boxel-font-sm); | ||
| } | ||
| </style> | ||
| </template> | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the file tree has focus and the user presses Enter on a file,
IndexedFileTree.handleKeydowncallsselectFile()and thenonFileConfirmed. Because the mini chooser wires both callbacks tohandleFileSelectedhere, one keyboard activation invokes@onSelecttwice, which can duplicate whatever the host does with that URL, such as inserting two file embeds.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] Fixed in 20f6f99.
MiniFileChoosernow routes@onFileSelectedto ahandleFileSelectOnlyaction that only updates the highlighted row, and routes@onFileConfirmedtohandleFileSelectedwhich is the sole caller ofonSelect. Click highlights; Enter confirms and firesonSelectonce. The integration test was updated to click-then-Enter.