diff --git a/src/SIL.XForge.Scripture/ClientApp/package-lock.json b/src/SIL.XForge.Scripture/ClientApp/package-lock.json index a42b4a8e685..194c0366065 100644 --- a/src/SIL.XForge.Scripture/ClientApp/package-lock.json +++ b/src/SIL.XForge.Scripture/ClientApp/package-lock.json @@ -138,6 +138,7 @@ "version": "2.1.0", "license": "MIT", "dependencies": { + "@biblionexus-foundation/scripture-utilities": "^0.0.7", "@bugsnag/js": "^7.21.0", "@sillsdev/scripture": "1.4.1", "@types/ajv-bsontype": "^1.0.1", @@ -148,13 +149,13 @@ "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.1.2", "lodash-es": "^4.17.21", - "mongodb": "^4.6.0", + "mongodb": "^4.17.2", "ot-json0": "^1.1.0", "rich-text": "^4.1.0", - "sharedb": "^1.9.2", + "sharedb": "^5.1.1", "sharedb-access": "^5.0.0", - "sharedb-milestone-mongo": "^1.0.0", - "sharedb-mongo": "^1.0.0", + "sharedb-milestone-mongo": "^2.0.0", + "sharedb-mongo": "^5.0.0", "ts-object-path": "^0.1.2", "websocket-json-stream": "^0.0.3", "ws": "^8.18.0" @@ -177,7 +178,7 @@ "jest-expect-message": "^1.1.3", "jest-teamcity-reporter": "^0.9.0", "prettier": "^3.3.3", - "sharedb-mingo-memory": "^1.2.0", + "sharedb-mingo-memory": "^4.0.1", "ts-jest": "^29.1.2", "ts-mockito": "^2.6.1", "typescript": "~5.2.2" diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.html new file mode 100644 index 00000000000..5fa073c0d3e --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.html @@ -0,0 +1,66 @@ + + + + + + + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.scss new file mode 100644 index 00000000000..eada085d112 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.scss @@ -0,0 +1,171 @@ +@use 'sass:color'; +@use 'src/variables' as vars; + +$inputTextColor: #64748b; +$selectedBookTextColor: vars.$blueMedium; +$accentColor: color.scale($selectedBookTextColor, $lightness: 40%); +$expandedBookBGColor: color.scale($selectedBookTextColor, $lightness: 95%); +$selectedChapterBGColor: color.scale($selectedBookTextColor, $lightness: 80%); +$menuFocusOutlineWidth: 2px; +$chapterItemDim: 2.5em; + +:host { + display: inline-block; +} + +button { + outline: 0; +} + +.menu-trigger { + --mat-outlined-button-pressed-state-layer-opacity: 0; + --mat-outlined-button-state-layer-color: transparent; + + &:focus-within { + outline: $menuFocusOutlineWidth solid $accentColor; + } + + input { + font-family: inherit; + font-size: inherit; + padding: 0; + border: 0; + outline: 0; + background: transparent; + width: 100%; + color: $inputTextColor; + font-weight: 500; + + &::placeholder { + color: $inputTextColor; + font-weight: 500; + } + } + + .trigger-open-content-wrapper { + display: none; + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + } + + &.menu-open { + .trigger-closed-content-wrapper { + visibility: hidden; // Keep space for sizing + } + + .trigger-open-content-wrapper { + display: flex; + justify-content: space-between; + } + } + + mat-icon { + font-size: 1.3em; + width: 1.3em; + height: 1.3em; + display: flex; + align-items: center; + } +} + +.trigger-closed-content-wrapper { + visibility: visible; + display: flex; + align-items: center; + gap: 8px; +} + +.book-wrapper { + border-radius: 0.3em; + overflow: hidden; + + &.selected { + background: $expandedBookBGColor; + + &.cursor { + background: color.scale($expandedBookBGColor, $lightness: -4%); + } + + .book { + font-weight: 600; + color: $selectedBookTextColor; + background-color: inherit; + cursor: default; + } + } +} + +.book { + position: relative; + display: flex; + border: 0; + width: 100%; + font-size: 0.9em; + background-color: transparent; + padding: 0.5em 0.5em 0.5em 1.4em; + + &:focus { + cursor: pointer; + background-color: #ebedf8; + } + + &::before { + content: ''; + position: absolute; + width: 0.15em; + height: calc(100% - 1em); + background-color: $accentColor; + border-radius: 0.4em; + inset-inline-start: 0.7em; + top: 0.5em; + } +} + +.chapter-display { + display: grid; + + // Column count from config, row height is 1fr + grid-template-columns: repeat(var(--book-chapter-combined-chooser-column-count), 1fr); + grid-template-rows: 1fr; + padding: 0 0.5em 0.5em; + + button { + border: 0.15em solid transparent; + border-radius: 5px; + background-color: inherit; + color: $selectedBookTextColor; + width: $chapterItemDim; + height: $chapterItemDim; + font-size: 0.8em; + font-weight: 300; + text-align: center; + + &.selected { + background-color: $selectedChapterBGColor; + font-weight: 700; + } + + &.cursor { + cursor: pointer; + border-color: $selectedBookTextColor; + font-weight: 700; + } + } +} + +.mat-icon { + opacity: 0.5; +} + +::ng-deep { + .book-chapter-combined-chooser-menu { + max-width: none !important; + max-height: 80vh; + padding: 0 0.2em; + position: relative; + top: $menuFocusOutlineWidth; // Push down to show input focus outline + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.ts new file mode 100644 index 00000000000..a8e3e8f1add --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.component.ts @@ -0,0 +1,552 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + DestroyRef, + ElementRef, + EventEmitter, + Inject, + InjectionToken, + Input, + OnChanges, + OnInit, + Output, + QueryList, + SimpleChanges, + ViewChild, + ViewChildren +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MAT_MENU_DEFAULT_OPTIONS, MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { TranslocoModule, TranslocoService } from '@ngneat/transloco'; +import { + animationFrames, + asapScheduler, + BehaviorSubject, + combineLatest, + distinctUntilChanged, + filter, + fromEvent, + observeOn, + pairwise, + withLatestFrom +} from 'rxjs'; +import { map, sample } from 'rxjs/operators'; +import { DOCUMENT } from 'xforge-common/browser-globals'; +import { I18nService } from 'xforge-common/i18n.service'; + +enum KeyCode { + ArrowUp = 'ArrowUp', + ArrowDown = 'ArrowDown', + ArrowLeft = 'ArrowLeft', + ArrowRight = 'ArrowRight', + Backspace = 'Backspace', + Delete = 'Delete', + Enter = 'Enter', + Escape = 'Escape', + Space = ' ' +} + +enum InputEventSource { + Mouse, + Keyboard +} + +export interface BookChapterChangeEvent { + book: number; + chapter: number; +} + +export interface BookChapterCombinedChooserConfig { + chapterColumnCount: number; +} + +export const BOOK_CHAPTER_COMBINED_CHOOSER_CONFIG = new InjectionToken( + 'BOOK_CHAPTER_COMBINED_CHOOSER_CONFIG', + { + factory: () => ({ + chapterColumnCount: 6 + }) + } +); + +@Component({ + selector: 'app-book-chapter-combined-chooser', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatMenuModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + TranslocoModule + ], + templateUrl: './book-chapter-combined-chooser.component.html', + styleUrl: './book-chapter-combined-chooser.component.scss', + providers: [{ provide: MAT_MENU_DEFAULT_OPTIONS, useValue: { ...MAT_MENU_DEFAULT_OPTIONS, hasBackdrop: false } }] +}) +export class BookChapterCombinedChooserComponent implements OnChanges, OnInit { + @Input() book?: number; + @Input() chapter?: number; + @Input() books: number[] = []; + @Input() chapters: { [book: number]: number[] } = {}; + @Output() bookChapterChange = new EventEmitter(); + + @ViewChild('trigger', { read: ElementRef }) menuTriggerEl?: ElementRef; + @ViewChild('trigger') menuTrigger?: MatMenuTrigger; + @ViewChild('textInput') textInput?: ElementRef; + @ViewChildren('bookButton') bookButtons?: QueryList>; + @ViewChildren('chapterButton') chapterButtons?: QueryList>; + + inputValue$ = new BehaviorSubject(''); + bookCursor$ = new BehaviorSubject(this.book ?? 0); + chapterCursor$ = new BehaviorSubject(0); + + books$ = new BehaviorSubject([]); + chapters$ = new BehaviorSubject<{ [book: number]: number[] }>({}); + + filteredBooks$ = new BehaviorSubject([]); + bookNames = new Map(); + + expandedBook$ = new BehaviorSubject(0); + expandedBookIndex: number = 0; + expandedBookChapters: number[] = []; + + lastInputEventSource: InputEventSource = InputEventSource.Keyboard; + + readonly chapterColumnWidth = this.config.chapterColumnCount; + readonly overlayPanelClass = 'book-chapter-combined-chooser-menu'; + + constructor( + private readonly destroyRef: DestroyRef, + @Inject(DOCUMENT) private readonly document: Document, + @Inject(BOOK_CHAPTER_COMBINED_CHOOSER_CONFIG) readonly config: BookChapterCombinedChooserConfig, + private readonly transloco: TranslocoService, + private readonly i18n: I18nService + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.books) { + if (this.book == null) { + this.book = this.books[0]; + } + + this.books$.next(this.books); + this.filterBooks(this.inputValue$.value); + } + + if (changes.chapters) { + this.chapters$.next(this.chapters); + } + } + + ngOnInit(): void { + if (this.books.length === 0) { + throw new Error('No books provided'); + } + + if (Object.keys(this.chapters).length === 0) { + throw new Error('No chapters provided'); + } + + if (this.book == null) { + this.book = this.books[0]; + } + + if (this.chapter == null) { + this.chapter = this.chapters[this.book][0]; + } + + this.expandedBook$.next(this.book); + + combineLatest([this.expandedBook$, this.chapters$]) + .pipe( + takeUntilDestroyed(this.destroyRef), + distinctUntilChanged(), + map(([expandedBook, chapters]) => chapters[expandedBook] ?? []) + ) + .subscribe(expandedBookChapters => { + this.expandedBookChapters = expandedBookChapters; + }); + + // Update expanded book index when expanded book or filtered books changes + combineLatest([this.expandedBook$, this.filteredBooks$]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([expandedBook, filteredBooks]) => { + this.expandedBookIndex = filteredBooks.indexOf(expandedBook); + }); + + // Add styles based on config + this.addConfigStyles(this.config); + + // Update book cursor when filtered books change + this.filteredBooks$ + .pipe(takeUntilDestroyed(this.destroyRef), pairwise()) + .subscribe(([filteredBooksPrev, filteredBooksCurr]) => { + const filteredBookForPrevCursor: number = (filteredBooksPrev ?? this.books)[this.bookCursor$.value]; + let newBookCursor: number = filteredBooksCurr.indexOf(filteredBookForPrevCursor); + + if (newBookCursor === -1) { + // If the book cursor is filtered out, reset it to the first book + newBookCursor = 0; + } + + if (newBookCursor === this.bookCursor$.value) { + // If the book cursor does not change, ensure the book button is focused + this.focusBookButton(newBookCursor); + } else { + this.bookCursor$.next(newBookCursor === -1 ? 0 : newBookCursor); + } + }); + + this.setupDocumentEventHandlers(); + + // Update filtered books when input value changes + this.inputValue$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(inputValue => { + this.filterBooks(inputValue); + }); + + // Update book names when books or locale changes + combineLatest([ + this.books$, + this.i18n.locale$, + this.transloco.events$.pipe(filter(e => e.type === 'translationLoadSuccess')) // Wait for translations to load + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.populateBookNames(); + }); + + // Focus book button or emit chapterCursor$ when book cursor changes + this.bookCursor$ + .pipe( + takeUntilDestroyed(this.destroyRef), + distinctUntilChanged(), + // 'asapScheduler' ensures bookCursor$ handler runs after chapterCursor$ handler when both are triggered in the + // the same synchronous code. + // This way, chapterCursor$ emissions from within bookCursor$ handler are handled after chapterCursor$ emissions + // that occur anywhere bookCursor$.next() may be called first. + observeOn(asapScheduler) + ) + .subscribe(bookCursor => { + // Only focus book button if book cursor is NOT on the expanded book + if (bookCursor !== this.expandedBookIndex) { + this.focusBookButton(bookCursor); + } + + // Update chapter cursor to index of selected chapter if book cursor is on the selected book. 0 otherwise. + this.chapterCursor$.next( + bookCursor === this.filteredBooks$.value.indexOf(this.book!) + ? (this.expandedBookChapters.indexOf(this.chapter!) ?? 0) + : 0 + ); + }); + + // Focus chapter button when chapter cursor changes and book cursor is on the expanded book + this.chapterCursor$ + .pipe(takeUntilDestroyed(this.destroyRef), withLatestFrom(this.bookCursor$), distinctUntilChanged()) + .subscribe(([chapterCursor, bookCursor]) => { + // Only focus chapter button if book cursor is on the expanded book + if (this.expandedBookIndex === bookCursor) { + this.focusChapterButton(chapterCursor); + } + }); + } + + handleTextInputKeydown(event: KeyboardEvent): void { + if (this.isNavOnlyKey(event.key)) { + event.preventDefault(); // Prevents scroll on arrow keys + this.keyNavBookChapterCursor(event.key); + return; + } + + switch (event.key) { + case KeyCode.Enter: + this.selectChapter(this.chapterCursor$.value); + break; + case KeyCode.Escape: + this.menuTrigger?.closeMenu(); + break; + } + } + + handleMenuKeydown(event: KeyboardEvent): void { + if (this.isNavKey(event.key)) { + this.keyNavBookChapterCursor(event.key); + return; + } + + if (this.isFocusableInput(event.key)) { + this.focusTextInput(() => { + // Forward the event to the input element after focusing it + this.textInput?.nativeElement?.dispatchEvent(new KeyboardEvent(event.type, event)); + }); + } + } + + handleMenuOpened(): void { + this.scrollToExpandedBook({ behavior: 'instant', block: 'start' }); + + setTimeout(() => { + // Set book/chapter cursors to the selected book/chapter + this.bookCursor$.next(this.expandedBookIndex); + this.chapterCursor$.next(this.expandedBookChapters.indexOf(this.chapter!)); + + // Wait until book focus resolves before focusing the input element + setTimeout(() => { + // Focus the input element so the user will see the blinking cursor + this.focusTextInput(); + }); + }); + } + + handleMenuClosed(): void { + this.reset(); + } + + handleTextInputClick(e: Event): void { + e.stopImmediatePropagation(); + } + + handleBookMouseEnter(bookIndex: number): void { + if (this.lastInputEventSource === InputEventSource.Mouse) { + this.bookCursor$.next(bookIndex); + } + } + + handleChapterMouseEnter(bookIndex: number, chapterIndex: number): void { + // Ignore 'mouseenter' events unless initiated by a mouse movement (not a scroll due to keyboard nav) + if (this.lastInputEventSource === InputEventSource.Mouse) { + this.bookCursor$.next(bookIndex); + setTimeout(() => { + this.chapterCursor$.next(chapterIndex); + }); + } + } + + selectBook(e: MouseEvent, book: number): void { + if (this.expandedBook$.value === book) { + this.expandedBook$.next(-1); + } else { + this.expandedBook$.next(book); + } + + this.bookCursor$.next(this.filteredBooks$.value.indexOf(book)); + + this.focusChapterButton(this.chapterCursor$.value); + this.scrollToExpandedBook({ behavior: 'instant', block: 'nearest' }); + + e.stopPropagation(); // Prevent the menu from closing + } + + selectChapter(chapter: number): void { + this.book = this.expandedBook$.value; + this.chapter = chapter; + this.bookChapterChange.emit({ book: this.book, chapter: this.chapter }); + + setTimeout(() => { + this.menuTrigger?.closeMenu(); + + setTimeout(() => { + this.menuTriggerEl?.nativeElement.focus(); + }); + }); + } + + private setupDocumentEventHandlers(): void { + // Close the menu when clicking outside the trigger or menu + fromEvent(this.document, 'click') + .pipe( + takeUntilDestroyed(this.destroyRef), + filter(() => this.menuTrigger?.menuOpen === true) + ) + .subscribe((e: Event) => { + if (!this.isClickInsideTriggerOrMenu(e)) { + this.menuTrigger?.closeMenu(); + } + }); + + fromEvent(this.document, 'mousemove') + .pipe( + takeUntilDestroyed(this.destroyRef), + filter(() => this.menuTrigger?.menuOpen === true), + sample(animationFrames()) + ) + .subscribe(() => { + this.lastInputEventSource = InputEventSource.Mouse; + }); + + fromEvent(this.document, 'keydown') + .pipe( + takeUntilDestroyed(this.destroyRef), + filter(() => this.menuTrigger?.menuOpen === true) + ) + .subscribe(() => { + this.lastInputEventSource = InputEventSource.Keyboard; + }); + } + + private getMenuPanel(): HTMLElement | null { + return this.document.querySelector(`.${this.overlayPanelClass}`); + } + + /** + * Scroll the expanded wrapper for the selected book into view. + */ + private scrollToExpandedBook(scrollOptions: ScrollIntoViewOptions): void { + setTimeout(() => { + const bookWrapper = this.bookButtons?.get(this.expandedBookIndex)?.nativeElement.parentElement; + if (bookWrapper == null) { + return; + } + + const menuContainer = this.getMenuPanel(); + if (menuContainer == null) { + return; + } + + // Scroll with provided options ('nearest' may cut off top of expanded book) + bookWrapper.scrollIntoView(scrollOptions); + + const bookWrapperRect = bookWrapper.getBoundingClientRect(); + const menuContainerRect = menuContainer.getBoundingClientRect(); + + // Ensure the top of the book wrapper is not cut off + if (bookWrapperRect.top < menuContainerRect.top) { + bookWrapper.scrollIntoView({ ...scrollOptions, block: 'start' }); + } + }); + } + + private isFocusableInput(key: string): boolean { + // Space is text if the input is already focused, but should be used as a selection trigger otherwise + return (key !== KeyCode.Space && key.length === 1) || key === KeyCode.Backspace || key === KeyCode.Delete; + } + + private keyNavBookChapterCursor(key: string): void { + let bookCursor: number = this.bookCursor$.value; + let chapterCursor: number = this.chapterCursor$.value; + + switch (key) { + case KeyCode.ArrowRight: + chapterCursor = Math.min(chapterCursor + 1, this.expandedBookChapters.length - 1); + break; + case KeyCode.ArrowLeft: + chapterCursor = Math.max(chapterCursor - 1, 0); + break; + case KeyCode.ArrowDown: + if ( + bookCursor === this.expandedBookIndex && + chapterCursor + this.chapterColumnWidth < this.expandedBookChapters.length + ) { + chapterCursor += this.chapterColumnWidth; + } else { + bookCursor = Math.min(bookCursor + 1, this.books.length - 1); + } + break; + case KeyCode.ArrowUp: + if (bookCursor === this.expandedBookIndex && chapterCursor - this.chapterColumnWidth >= 0) { + chapterCursor -= this.chapterColumnWidth; + } else { + bookCursor = Math.max(bookCursor - 1, 0); + } + break; + } + + this.bookCursor$.next(bookCursor); + this.chapterCursor$.next(chapterCursor); + } + + private focusBookButton(index: number): void { + setTimeout(() => { + if (!this.menuTrigger?.menuOpen) { + return; + } + + const bookButton = this.bookButtons?.get(index)?.nativeElement; + bookButton?.focus(); // Focus with scroll + }); + } + + private focusChapterButton(index: number): void { + setTimeout(() => { + if (!this.menuTrigger?.menuOpen) { + return; + } + + const chapterButton = this.chapterButtons?.get(index)?.nativeElement; + chapterButton?.focus({ + // Scroll on focus when navigating by keyboard (mouse entering an expanded book is jerky) + preventScroll: this.lastInputEventSource === InputEventSource.Mouse + }); + }); + } + + private focusTextInput(callback?: () => void): void { + this.textInput?.nativeElement.focus(); + + setTimeout(() => { + callback?.(); + }); + } + + private isNavKey(key: string): boolean { + return ( + key === KeyCode.ArrowDown || key === KeyCode.ArrowUp || key === KeyCode.ArrowLeft || key === KeyCode.ArrowRight + ); + } + + private isNavOnlyKey(key: string): boolean { + return key === KeyCode.ArrowDown || key === KeyCode.ArrowUp; + } + + private isClickInsideTriggerOrMenu(e: Event): boolean { + return ( + this.menuTriggerEl?.nativeElement.contains(e.target as HTMLElement) || + this.getMenuPanel()?.contains(e.target as HTMLElement) || + false + ); + } + + private populateBookNames(): void { + this.bookNames.clear(); + + for (const book of this.books) { + this.bookNames.set(book, this.i18n.localizeBook(book)); + } + } + + private filterBooks(filterText: string): void { + if (filterText === '') { + this.filteredBooks$.next(this.books); + return; + } + + const filterTextLower: string = filterText.toLowerCase(); + + this.filteredBooks$.next( + this.books.filter(book => { + return this.bookNames.get(book)?.toLowerCase().includes(filterTextLower); + }) + ); + } + + private addConfigStyles(config: BookChapterCombinedChooserConfig): void { + this.document.body.style.setProperty( + '--book-chapter-combined-chooser-column-count', + config.chapterColumnCount.toString() + ); + } + + private reset(): void { + this.expandedBook$.next(this.book!); + this.inputValue$.next(''); + this.bookCursor$.next(0); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.stories.ts new file mode 100644 index 00000000000..26602144800 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/book-chapter-combined-chooser/book-chapter-combined-chooser.stories.ts @@ -0,0 +1,21 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { keyBy, mapValues } from 'lodash-es'; +import { arrayOfIntsFromOne } from 'xforge-common/test-utils'; +import { BookChapterCombinedChooserComponent } from './book-chapter-combined-chooser.component'; + +const CANON_SIZE = 66; + +export default { + title: 'Shared/Book & Chapter Combined Chooser', + component: BookChapterCombinedChooserComponent, + args: { + books: arrayOfIntsFromOne(CANON_SIZE), + book: 1, + chapters: mapValues(keyBy(arrayOfIntsFromOne(CANON_SIZE)), () => arrayOfIntsFromOne(50)), + chapter: 1 + } +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss b/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss index cfa08018836..6303ac8e5af 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/material-styles.scss @@ -166,6 +166,7 @@ html, --mat-menu-item-label-text-tracking: initial; --mdc-text-button-label-text-tracking: initial; --mdc-filled-button-label-text-tracking: initial; + --mdc-outlined-button-label-text-tracking: initial; } mat-list-item {