diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f6f6ae..24ab4233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Change-log](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +# 10.3.0 (2023-10-26) +- Added new component `vgr-selectablelist` # 10.2.0 (2023-09-29) - Added new look on icon for `vgr-header-menu` with and without expanding. Also added a hover effect on icon and possibility to click directly on icon which was not possible before. # 10.1.3 (2023-09-19) diff --git a/projects/komponentkartan/package.json b/projects/komponentkartan/package.json index 308488fb..747c9b66 100644 --- a/projects/komponentkartan/package.json +++ b/projects/komponentkartan/package.json @@ -1,6 +1,6 @@ { "name": "vgr-komponentkartan", - "version": "10.2.0", + "version": "10.3.0-beta13", "peerDependencies": { "@angular/cdk": ">=14.2.3", "@angular/common": ">=14.2.4", diff --git a/projects/komponentkartan/src/lib/controls/scrollbar/scrollbar.component.html b/projects/komponentkartan/src/lib/controls/scrollbar/scrollbar.component.html index 41019943..8462d0f5 100644 --- a/projects/komponentkartan/src/lib/controls/scrollbar/scrollbar.component.html +++ b/projects/komponentkartan/src/lib/controls/scrollbar/scrollbar.component.html @@ -1,3 +1,9 @@ - + diff --git a/projects/komponentkartan/src/lib/controls/scrollbar/scrollbar.component.ts b/projects/komponentkartan/src/lib/controls/scrollbar/scrollbar.component.ts index 425be183..d1c05b95 100644 --- a/projects/komponentkartan/src/lib/controls/scrollbar/scrollbar.component.ts +++ b/projects/komponentkartan/src/lib/controls/scrollbar/scrollbar.component.ts @@ -9,6 +9,9 @@ import { NgScrollbar } from 'ngx-scrollbar'; export class ScrollbarComponent { @ViewChild(NgScrollbar) scrollable: NgScrollbar; @Input() autoheightDisabled = 'false'; + @Input() autowidthDisabled = 'true'; + @Input() visibility = 'native'; + @Input() maxHeight; constructor() { } diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.column.component.ts b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.column.component.ts new file mode 100644 index 00000000..459f31dc --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.column.component.ts @@ -0,0 +1,14 @@ +import { Component, Input, HostBinding } from '@angular/core'; + +@Component({ + selector: 'vgr-selectablelist-column', + template: '' +}) +export class SelectablelistColumnComponent { + + @HostBinding('class.right') @Input() alignRight = false; + @HostBinding('class.center') @Input() alignCenter = false; + + constructor() { } + +} diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.html b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.html new file mode 100644 index 00000000..e4b80ab2 --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.html @@ -0,0 +1,13 @@ +
+ +
+ +
+ +
+
+ +
+
+
+
diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.scss b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.scss new file mode 100644 index 00000000..e44a2387 --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.scss @@ -0,0 +1,176 @@ +@import "../../../assets/partials/_settings.sizes.scss"; +@import "../../../assets/partials/_settings.colors.scss"; +@import "../../../assets/partials/_settings.fonts.scss"; +@import "../../../assets/partials/objects.images"; +@import "../../../assets/partials/base.themify"; + +:host::ng-deep { + outline: 0; + position: relative; + z-index: 0; + width: 100%; + + vgr-selectablelist-header, + vgr-selectablelist-row { + display: table-row; + height: 32px; + } + + vgr-selectablelist-row:nth-child(even) { + background: white; + + &.selected, + &.selected:hover { + @include themify($themes) { + background: themed('primaryColorDimmed') !important; + border-left-color: themed('primaryColor') !important; + } + border-left: 4px solid; + } + + &.focused { + outline: 3px solid #2275d3; + outline-offset: -3px; + } + + + &.groupheader { + font-weight: bold; + + vgr-selectablelist-column { + padding-left: 10px; + } + } + } + + vgr-selectablelist-row:nth-child(odd) { + background: #f0f0f0; + + &.selected, + &.selected:hover { + @include themify($themes) { + background: themed('primaryColorDimmed') !important; + border-left-color: themed('primaryColor') !important; + } + border-left: 4px solid; + } + + &.focused { + outline: 3px solid #2275d3; + outline-offset: -3px; + } + } + + vgr-selectablelist-header-column, + vgr-selectablelist-column { + display: table-cell; + height: 22px; + line-height: 22px; + padding: 5px; + + &:first-child { + padding-left: 20px; + } + + &.center { + text-align: center; + } + + &.right { + text-align: right; + } + } + + .list-wrapper.active { + vgr-selectablelist-row:not(.groupheader) { + cursor: pointer; + + &:hover { + font-weight: bold; + background: transparent; + letter-spacing: -0.15px; + + &.selected { + @include themify($themes) { + background: themed('primaryColorDimmed') !important; + border-left-color: themed('primaryColor') !important; + } + border-left: 4px solid; + } + } + } + vgr-selectablelist-row:not(.selectable) { + cursor: default; + &:hover { + font-weight: normal; + background: transparent; + letter-spacing: 0; + &:nth-child(odd) { + // background: #f0f0f0; + @include themify($themes) { + background: themed('primaryColorDimmed') !important; + border-left-color: themed('primaryColor') !important; + } + border-left: 4px solid; + } + } + } + } + + .scrollbar-container { + @include themify($themes) { + border-top-color: themed('primaryColor') !important; + } + border-top: 2px solid; + border-bottom: 1px solid #97939f; + border-left: 1px solid #97939f; + border-right: 1px solid #97939f; + } + + @media screen and (min-width: $desktop-width--medium) { + .list-wrapper.active { + vgr-selectablelist-row:hover { + letter-spacing: -0.19px; + } + } + } + + // IE HACK to fix outline bug on focused states + @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { + /* IE10+ specific styles go here */ + vgr-selectablelist-row.focused { + border: 3px solid #2275d3 !important; + outline: none !important; + } + } + .scroll-wrapper { + position: relative; + background: white; + display: block; + // max-height: 244px; + // overflow: auto; + padding: 10px 0px; + } + + .remove-scrollbar { + .scroll-wrapper { + width: calc(100% + 16px) !important; + } + scrollbar-y { + display: none !important; + } + } +} + +.list-header { + display: table; + width: 100%; + border-spacing: 0px !important; +} + +.list-wrapper { + border-collapse: collapse; + display: table; + width: 100%; +} + diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.spec.ts b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.spec.ts new file mode 100644 index 00000000..662cf108 --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.spec.ts @@ -0,0 +1,269 @@ +import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; + +import { SelectablelistComponent } from './selectablelist.component'; +import { SelectablelistHeaderColumnComponent } from './selectablelist.header-column.component'; +import { SelectablelistColumnComponent } from './selectablelist.column.component'; +import { SelectablelistHeaderComponent } from './selectablelist.header.component'; +import { SelectablelistRowComponent } from './selectablelist.row.component'; + +import { Component, DebugElement, SimpleChange } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { SelectablelistService } from './selectablelist.service'; + +@Component({ + selector: 'vgr-selectablelist-test', + template: ` + + + Rubrik + värde + + + Djur + + + + Hund + 1 + + + Katt + 2 + + + Kanin + 3 + + + Spndel + 4 + + + Kråka + 5 + + + Anka + 6 + + + Elefant + 7 + + + Zebra + 8 + + + ` +}) class TestSelectablelistComponent { } + + +describe('SelectablelistComponent', () => { + let component: SelectablelistComponent; + let fixture: ComponentFixture; + let rootElement: DebugElement; + let selectionChangedSpy: jasmine.Spy; + let rows: SelectablelistRowComponent[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + TestSelectablelistComponent, + SelectablelistComponent, + SelectablelistHeaderComponent, + SelectablelistHeaderColumnComponent, + SelectablelistRowComponent, + SelectablelistColumnComponent + ], + providers: [ + { provide: SelectablelistService }, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestSelectablelistComponent); + fixture.detectChanges(); + component = fixture.debugElement.query(By.directive(SelectablelistComponent)).componentInstance; + rootElement = fixture.debugElement; + selectionChangedSpy = spyOn(component.selectedChanged, 'emit').and.callThrough(); + rows = component.rows.toArray(); + }); + + describe('When activated', () => { + beforeEach(() => { + component.active = false; + component.ngOnChanges({ + active: new SimpleChange(true, false, false) + }); + component.active = true; + component.ngOnChanges({ + active: new SimpleChange(false, true, false) + }); + fixture.detectChanges(); + }); + + xit('should not select the first selectable row (row 2)', () => { + const isAnySelected = rows.some(r => r.selected); + expect(isAnySelected).toBe(true); + expect(rows[1].selected).toBe(true); + }); + + it('should not select the groupheader row (even if its the first)', () => { + expect(rows[0].selected).not.toBe(true); + expect(rows[0].groupheader).toBe(true); + }); + + it('second headercolumn should be align right', () => { + const secondColumn = rootElement.queryAll(By.directive(SelectablelistHeaderColumnComponent))[1]; + expect(secondColumn.classes['right']).toBe(true); + }); + + }); + + describe('when "Kanin" row is clicked', () => { + beforeEach(fakeAsync(() => { + selectionChangedSpy.calls.reset(); + component.clearSelection(); + component.active = true; + const kaninRow = rootElement.queryAll(By.directive(SelectablelistRowComponent)) + .filter(item => item.nativeElement.innerText.includes('Kanin'))[0]; + kaninRow.triggerEventHandler('click', {}); + fixture.detectChanges(); + tick(400); + fixture.detectChanges(); + })); + + it('"Kaninrow" should be the selected', () => { + expect(rows[3].selected).toBe(true); + expect(rows.filter(row => row.selected === true).length).toBe(1); + }); + + it('should emit event that selection is changed', () => { + expect(selectionChangedSpy).toHaveBeenCalledWith([3]); + }); + }); + + xdescribe('when both "Kanin" and "Hund" rows are clicked', () => { // removed because multiselect not yet implemented + beforeEach(fakeAsync(() => { + selectionChangedSpy.calls.reset(); + component.clearSelection(); + component.active = true; + const kaninRow = rootElement.queryAll(By.directive(SelectablelistRowComponent)) + .filter(item => item.nativeElement.innerText.includes('Kanin'))[0]; + const hundRow = rootElement.queryAll(By.directive(SelectablelistRowComponent)) + .filter(item => item.nativeElement.innerText.includes('Hund'))[0]; + kaninRow.nativeElement.click(); + hundRow.nativeElement.click(); + tick(400); + fixture.detectChanges(); + })); + + it('Both rows should be selected', () => { + expect(rows[3].selected).toBe(true); + expect(rows[1].selected).toBe(true); + expect(rows.filter(row => row.selected === true).length).toBe(2); + }); + + it('should emit event that selection is changed', () => { + expect(selectionChangedSpy).toHaveBeenCalledWith([1, 3]); + }); + }); + + // Dessa tester failar väldigt ofta lokalt (men aldrig på servern), Bör ses över någon gång, tills vidare har jag inaktiverat dem /Arvid + xdescribe('Setting focus to the list', () => { + let list: DebugElement; + beforeEach(() => { + selectionChangedSpy.calls.reset(); + component.clearSelection(); + component.active = true; + list = rootElement.query(By.directive(SelectablelistComponent)); + list.nativeElement.focus(); + fixture.detectChanges(); + }); + + it('should set focusclass and aria-activedecendants', () => { + const row = rootElement.queryAll(By.directive(SelectablelistRowComponent))[0]; + expect(row.classes['focused']).toBe(true); + expect(list.attributes['aria-activedescendant']).toBe('djurlistan-row0'); + }); + + describe('Pressing arrow up twice', () => { + beforeEach(() => { + list.triggerEventHandler('keydown', { keyCode: 38, preventDefault: () => { } }); + list.triggerEventHandler('keydown', { keyCode: 38, preventDefault: () => { } }); + fixture.detectChanges(); + }); + it('should focus on the eigth element in the list', () => { + const row = rootElement.queryAll(By.directive(SelectablelistRowComponent))[7]; + expect(row.classes['focused']).toBe(true); + }); + + describe('pressing down 7 times', () => { + beforeEach(() => { + list.triggerEventHandler('keydown', { keyCode: 40, preventDefault: () => { } }); + list.triggerEventHandler('keydown', { keyCode: 40, preventDefault: () => { } }); + list.triggerEventHandler('keydown', { keyCode: 40, preventDefault: () => { } }); + list.triggerEventHandler('keydown', { keyCode: 40, preventDefault: () => { } }); + list.triggerEventHandler('keydown', { keyCode: 40, preventDefault: () => { } }); + list.triggerEventHandler('keydown', { keyCode: 40, preventDefault: () => { } }); + list.triggerEventHandler('keydown', { keyCode: 40, preventDefault: () => { } }); + fixture.detectChanges(); + }); + it('Row 6 should be focused', () => { + const row = rootElement.queryAll(By.directive(SelectablelistRowComponent))[5]; + expect(row.classes['focused']).toBe(true); + }); + + describe('pressing space', () => { + beforeEach(() => { + selectionChangedSpy.calls.reset(); + list.triggerEventHandler('keydown', { keyCode: 32, preventDefault: () => { } }); + list.triggerEventHandler('keydown', { keyCode: 32, preventDefault: () => { }, repeat: true }); + fixture.detectChanges(); + }); + it('should select the row and emit event', () => { + expect(rows[5].selected).toBe(true); + expect(selectionChangedSpy).toHaveBeenCalledWith([5]); + }); + }); + }); + }); + }); + + describe('Removing focus from the list', () => { + let list: DebugElement; + beforeEach(() => { + component.active = true; + list = rootElement.query(By.directive(SelectablelistComponent)); + list.nativeElement.focus(); + fixture.detectChanges(); + list.nativeElement.blur(); + fixture.detectChanges(); + }); + it('should remove the focusclass and aria-activedecendants', () => { + const row = rootElement.queryAll(By.directive(SelectablelistRowComponent))[0]; + expect(row.classes['focused']).not.toBe(true); + expect(component.activeDecendant).toBe(null); + }); + }); + + + describe('When deactivated', () => { + beforeEach(() => { + component.active = false; + component.ngOnChanges({ + active: new SimpleChange(true, false, true) + }); + fixture.detectChanges(); + }); + + it('should clear all selected', () => { + const isanySelected = component.rows.toArray().some(row => row.selected); + expect(isanySelected).toBe(false); + }); + }); + +}); diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.ts b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.ts new file mode 100644 index 00000000..549263c2 --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.component.ts @@ -0,0 +1,335 @@ +import { + Component, + ElementRef, + AfterContentInit, + HostListener, + ContentChildren, + QueryList, + Input, + OnChanges, + SimpleChanges, + Output, + EventEmitter, + OnDestroy, + HostBinding, + ViewChild, + ContentChild +} from '@angular/core'; +import { SelectablelistHeaderComponent } from './selectablelist.header.component'; +import { SelectablelistRowComponent } from './selectablelist.row.component'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { SelectablelistService } from './selectablelist.service'; +import { GridSortDirection } from '../sort-arrow/sort-arrow.component'; +import { ScrollbarComponent } from '../scrollbar/scrollbar.component'; + +@Component({ + selector: 'vgr-selectablelist', + templateUrl: './selectablelist.component.html', + styleUrls: ['./selectablelist.component.scss'] +}) +export class SelectablelistComponent implements AfterContentInit, OnChanges, OnDestroy { + + headersPresent: boolean; + controlPressed = false; + focusedRow: number = null; + + sortOption: { column: number; sortDirection: GridSortDirection } = { + column: null, + sortDirection: GridSortDirection.None, + }; + sortDirection = GridSortDirection; + + @HostBinding('attr.aria-activedescendant') activeDecendant = null; + @HostBinding('attr.role') role = 'listbox'; + @HostBinding('attr.aria-multiselectable') multi = true; + @HostBinding('attr.tabIndex') tabIndex = 0; + + @Input() active: boolean; + @Input() useScrollbar: boolean = true; + @Input() maxHeight: number; + @HostBinding('attr.id') @Input() id: string; + @Output() selectedChanged = new EventEmitter(); + + @ContentChild(SelectablelistHeaderComponent) header: SelectablelistHeaderComponent; + + @ContentChildren(SelectablelistRowComponent) rows: QueryList; + + @ViewChild('scrollWrapper') scrollWrapper; + @ViewChild('scrollable') scrollable: ScrollbarComponent; + + private ngUnsubscribe = new Subject(); + + lastSelectedItemIndex: number; + isClicked: boolean; + showScrollbar: boolean; + + @HostListener('keydown', ['$event']) onKeydownHandler(event: KeyboardEvent) { + this.isClicked = false; + if (event.repeat) { return; } + if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Up' || event.key === 'Down') { + event.preventDefault(); + let newIndex: number; + if (event.key === 'ArrowDown' || event.key === 'Down') { + newIndex = this.focusedRow < this.rows.length - 1 ? this.focusedRow + 1 : 0; + } else { + newIndex = this.focusedRow > 0 ? this.focusedRow - 1 : this.rows.length - 1; + } + this.focusRow(newIndex); + } else if (event.key === ' ') { + event.preventDefault(); + this.changeActiveStatus(this.focusedRow); + } + } + + @HostListener('click', ['$event']) onClick(event) { + this.isClicked = true; + } + + @HostListener('focusin', ['$event']) onCompFocus(event) { + + if (this.isClicked) { + // do nothing + this.isClicked = false; + } else { + const selectedRows = this.rows.filter(r => r.selected === true); + if (selectedRows) { + this.focusedRow = this.rows.toArray().indexOf(selectedRows[0]) + this.focusRow(this.focusedRow); + } else { + this.selectFirstSelectable() + } + } + } + + @HostListener('focusout', ['$event']) onCompBlur(event) { + const rows = this.rows.toArray(); + rows.forEach(row => { row.focused = false; }); + // this.focusedRow = null; + this.activeDecendant = null; + } + + @HostListener('window:resize', []) alignColumns() { + const el = this.elRef.nativeElement; + const row = el.querySelector('vgr-selectablelist-row'); + if (row) { + const rowWidths = Array.from(row.querySelectorAll('vgr-selectablelist-column')) + .map((item: HTMLElement) => item.clientWidth); + + const headers = Array.from(el.querySelectorAll('vgr-selectablelist-header-column')); + headers.pop(); // dont want to set a size on the last column that can brake the alignments + headers.forEach((element: HTMLElement, index) => { + element.style.width = rowWidths[index] + 'px'; + }); + + setTimeout(() => this.setInternalIds(), 0); + } + } + + constructor(public elRef: ElementRef, private selectablelistService: SelectablelistService) {} + + ngOnDestroy() { + this.ngUnsubscribe.next(null); + this.ngUnsubscribe.complete(); + } + + ngOnChanges(changes: SimpleChanges) { + if (!changes.active || this.rows === undefined) { + return; + } + + if (changes.active.firstChange === true) { + this.selectFirstSelectable(); + } + + if (changes.active.currentValue === true && changes.active.previousValue === false) { + if (this.lastSelectedItemIndex !== undefined) { + this.changeActiveStatus(this.lastSelectedItemIndex, false) + } + } + + if (changes.active.currentValue === false) { + this.clearSelection(); + this.selectedChanged.emit([]); + } + } + + ngAfterContentInit() { + this.setScrollbarOnHeightChange(); + + setTimeout(() => this.alignColumns(), 100); + if (this.header) { + this.headersPresent = true; + } + + this.rows.changes.pipe(takeUntil(this.ngUnsubscribe)).subscribe(() => { + setTimeout(() => { + this.setScrollbarOnHeightChange(); + this.alignColumns() + }, 100); + }); + + this.selectablelistService.clickedRowRequested + .pipe(takeUntil(this.ngUnsubscribe)).subscribe((row: SelectablelistRowComponent) => { + this.handleRowClicked(row); + }); + + if (this.active) { + setTimeout(() => { + this.selectFirstSelectable(); + }, 200); + } + } + + setScrollbarOnHeightChange() { + if (this.useScrollbar === false) { + this.showScrollbar = false; + return; + } + // Fix för att ta bort scrollbar om height är mindre än maxheighten för tabellen (244px) + setTimeout(() => { + if (this.scrollWrapper.nativeElement.clientHeight < this.maxHeight) { + this.showScrollbar = false; + } else { + this.showScrollbar = true; + } + }, 20); + } + + // sortColumn(clickedHeader: SelectablelistHeaderColumnComponent) { + // const subIndex = clickedHeader.id.lastIndexOf('-') + 1; + // const index = +clickedHeader.id.substring(subIndex); + // const rowValues = this.rows.toArray(); + + // this.sortOption.sortDirection = + // this.sortOption.sortDirection === GridSortDirection.Ascending && + // this.sortOption.column === index + // ? GridSortDirection.Descending + // : GridSortDirection.Ascending; + // this.sortOption.column = index; + + + // console.log('here') + // rowValues.sort((a, b) => { + // const aElement = a.elem.nativeElement; + // const bElement = b.elem.nativeElement; + // const aIndex: any = (aElement.children[index] as HTMLElement).innerText.replace(/\s/g, '').replace(',', '.'); + // const bIndex: any = (bElement.children[index] as HTMLElement).innerText.replace(/\s/g, '').replace(',', '.'); + // const valA = isNaN(aIndex) ? aIndex : parseFloat(aIndex); + // const valB = isNaN(bIndex) ? bIndex : parseFloat(bIndex); + // return valA > valB ? 1 : valB > valA ? -1 : 0; + // }); + + // if (this.sortOption.sortDirection === GridSortDirection.Descending) { + // rowValues.reverse(); + // } + + // this.rows.reset(rowValues); + + // this.rows = new QueryList(); + // this.rows.notifyOnChanges(); + // console.log('in sort: ', this.rows) + + // } + + + handleRowClicked(row: SelectablelistRowComponent) { + const index = this.rows.toArray().indexOf(row); + this.changeActiveStatus(index); + + } + + setInternalIds() { + this.rows.forEach((row, index) => { + row.id = `${this.id}-row${index}`; + }); + } + + clearSelection() { + const lastSelectedItem = this.rows?.filter(x => x.selected === true)[0]; + this.lastSelectedItemIndex = this.rows.toArray().indexOf(lastSelectedItem) + this.rows?.forEach(row => { row.selected = false; row.focused = false; }); + } + + selectFirstSelectable() { + const rows = this.rows.toArray(); + + const firstNonHeader = this.rows.filter(row => row.groupheader === false)[0]; + const index = rows.indexOf(firstNonHeader); + this.changeActiveStatus(index); + } + + focusRow(index) { + const rows = this.rows.toArray(); + if (rows.length === 0) { + return; + } + if (index === -1) { + return; + } + + rows.forEach(row => { row.focused = false; }); + if (index === 0) { + rows[index].focused = true; + } else { + rows[index].focused = !rows[index].focused; + } + + this.focusedRow = index; + this.activeDecendant = rows[index].id; + if (!this.isRowVisible(this.activeDecendant)) { + this.scrollable.scrollable.scrollToElement('#' + this.activeDecendant, {duration: 500}); + } + } + + isRowVisible(id) { + const container = this.elRef.nativeElement.querySelector('.ng-scroll-viewport'); + if (!container) { + return true; + } + const element = document.getElementById(id); + + const cTop = container.scrollTop; + const cBottom = cTop + container.clientHeight; + + // Get element properties + const eTop = element.offsetTop; + const eBottom = eTop + element.clientHeight; + + // Check if in view + return (eTop >= cTop && eBottom <= cBottom); + } + + changeActiveStatus(index, setFocus = true) { + const rows = this.rows.toArray(); + if (rows.length === 0) { + return; + } + + if (index === -1) { + return; + } + + this.clearSelection(); + let selectedRowClickable = true; + if (rows[index]) { + selectedRowClickable = (rows[index].groupheader === false); // && rows[index].selectable === true; + } + + this.focusedRow = index; + if (this.focusedRow >= 0 && setFocus) { + this.focusRow(index); + } + + + if (this.active && selectedRowClickable) { + setTimeout(() => { + rows[index].selected = true; + const selected = this.rows.filter(item => item.selected).map(item => item.value); + this.selectedChanged.emit(selected); + }, 40); + + } + } + +} diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.header-column.component.ts b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.header-column.component.ts new file mode 100644 index 00000000..62610473 --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.header-column.component.ts @@ -0,0 +1,25 @@ +import { Component, OnInit, HostBinding, Input, HostListener, ElementRef } from '@angular/core'; +import { Guid } from '../../utils/guid'; +import { SelectablelistService } from './selectablelist.service'; +@Component({ + selector: 'vgr-selectablelist-header-column', + template: '' +}) +export class SelectablelistHeaderColumnComponent implements OnInit { + + @HostBinding('class.right') @Input() alignRight = false; + @HostBinding('class.center') @Input() alignCenter = false; + id: string; + @HostListener('click') toggleSelected() { + this.selectablelistService.headerClicked(this); + } + constructor(public elem: ElementRef, private selectablelistService: SelectablelistService) {} + + ngOnInit() { + const uniqeId = Guid.newGuid(); + const index = Array.from(this.elem.nativeElement.parentNode.children).indexOf(this.elem.nativeElement); + this.id = `${uniqeId}-header-${index}`; + } + + +} diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.header.component.ts b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.header.component.ts new file mode 100644 index 00000000..c8944ac5 --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.header.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'vgr-selectablelist-header', + template: '' +}) +export class SelectablelistHeaderComponent { + + constructor() { } + +} diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.row.component.ts b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.row.component.ts new file mode 100644 index 00000000..e560af9b --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.row.component.ts @@ -0,0 +1,46 @@ +import { Component, OnInit, HostListener, HostBinding, Output, EventEmitter, Input, ElementRef } from '@angular/core'; +import { SelectablelistService } from './selectablelist.service'; + +@Component({ + selector: 'vgr-selectablelist-row', + template: '' +}) +export class SelectablelistRowComponent implements OnInit { + + @Output() rowClicked = new EventEmitter(); + @Input() value: any; + @HostBinding('class.groupheader') + @Input() groupheader = false; + + @HostBinding('class.selectable') + @Input() selectable = true; + + + @HostBinding('class.selected') + @HostBinding('attr.aria-selected') selected = false; + @HostBinding('class.focused') focused = false; + @HostBinding('attr.role') role = 'option'; + @HostBinding('attr.id') id: string; + + @HostListener('click') toggleSelected() { + if (this.groupheader || !this.selectable) { + this.selected = null; + } else { + this.selectablelistService.requestRowClicked(this); + } + } + + constructor(public elem: ElementRef, private selectablelistService: SelectablelistService) { } + + ngOnInit() { + const parentid = this.elem.nativeElement.closest('vgr-selectablelist').id; + const index = Array.from(this.elem.nativeElement.parentNode.children).indexOf(this.elem.nativeElement); + this.id = `${parentid}-row${index}`; + if (this.groupheader) { + this.selected = null; + } else if (!this.selectable) { + this.selected = false; + } + } + +} diff --git a/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.service.ts b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.service.ts new file mode 100644 index 00000000..d032681d --- /dev/null +++ b/projects/komponentkartan/src/lib/controls/selectablelist/selectablelist.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class SelectablelistService { + + private clickedRowRequestedSource = new Subject(); + private clickedHeaderRequestedSource = new Subject(); + + clickedRowRequested = this.clickedRowRequestedSource.asObservable(); + clickedHeaderRequested = this.clickedHeaderRequestedSource.asObservable(); + + requestRowClicked(row: any) { + setTimeout(() => { + this.clickedRowRequestedSource.next(row); + }); + } + + headerClicked(header: any) { + setTimeout(() => { + this.clickedHeaderRequestedSource.next(header); + }); + } +} + diff --git a/projects/komponentkartan/src/lib/index.ts b/projects/komponentkartan/src/lib/index.ts index 49ae3a26..95999a5e 100644 --- a/projects/komponentkartan/src/lib/index.ts +++ b/projects/komponentkartan/src/lib/index.ts @@ -103,4 +103,10 @@ export { EditableTableColumnComponent } from './controls/editable-table/editable export { EditableTableRowComponent } from './controls/editable-table/editable-table-row.component'; export { CheckboxGroupComponent } from './controls/checkbox/checkbox-group.component'; +export { SelectablelistComponent } from './controls/selectablelist/selectablelist.component'; +export { SelectablelistRowComponent } from './controls/selectablelist/selectablelist.row.component'; +export { SelectablelistHeaderColumnComponent } from './controls/selectablelist/selectablelist.header-column.component'; +export { SelectablelistHeaderComponent } from './controls/selectablelist/selectablelist.header.component'; +export { SelectablelistColumnComponent } from './controls/selectablelist/selectablelist.column.component'; + export { Guid } from './utils/guid'; diff --git a/projects/komponentkartan/src/lib/komponentkartan.module.ts b/projects/komponentkartan/src/lib/komponentkartan.module.ts index 0c54f091..cc253d42 100644 --- a/projects/komponentkartan/src/lib/komponentkartan.module.ts +++ b/projects/komponentkartan/src/lib/komponentkartan.module.ts @@ -15,8 +15,6 @@ registerLocaleData(localeSv); import { NgScrollbarModule } from 'ngx-scrollbar'; import { IconModule } from './controls/icon/icon.module'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { ScrollbarComponent } from './controls/scrollbar/scrollbar.component'; -import { EditableTableComponent } from './controls/editable-table/editable-table.component'; // const DEFAULT_PERFECT_SCROLLBAR_CONFIG: PerfectScrollbarConfigInterface = { @@ -116,7 +114,12 @@ import { EditableTableComponent } from './controls/editable-table/editable-table type.EditableTableColumnComponent, type.RadiobuttonGroupComponent, type.RadiobuttonItemComponent, - type.CheckboxGroupComponent + type.CheckboxGroupComponent, + type.SelectablelistComponent, + type.SelectablelistHeaderComponent, + type.SelectablelistColumnComponent, + type.SelectablelistHeaderColumnComponent, + type.SelectablelistRowComponent ], exports: [ type.SafePipe, @@ -201,7 +204,12 @@ import { EditableTableComponent } from './controls/editable-table/editable-table type.EditableTableColumnComponent, type.RadiobuttonGroupComponent, type.RadiobuttonItemComponent, - type.CheckboxGroupComponent + type.CheckboxGroupComponent, + type.SelectablelistComponent, + type.SelectablelistHeaderComponent, + type.SelectablelistColumnComponent, + type.SelectablelistHeaderColumnComponent, + type.SelectablelistRowComponent ], providers: [ type.ModalService, diff --git a/src/app/app.component.html b/src/app/app.component.html index 95b2f252..051ef287 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -40,6 +40,7 @@ + diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ba253080..f32e4036 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -68,6 +68,7 @@ import { TabButtonModule } from './tab-button/tab-button.module'; import { ComboboxDocumentationComponent } from './combobox-documentation/combobox-documentation.component'; import { HighlightCodeDirective } from './directives/highlight-code.directive'; import { EditableTableDocumentationComponent } from './editable-table-documentation/editable-table-documentation.component'; +import { SelectablelistDocumentationComponent } from './selectablelist-documentation/selectablelist-documentation.component'; @NgModule({ declarations: [ @@ -113,7 +114,8 @@ import { EditableTableDocumentationComponent } from './editable-table-documentat NotificationDocumentationComponent, ComboboxDocumentationComponent, HighlightCodeDirective, - EditableTableDocumentationComponent + EditableTableDocumentationComponent, + SelectablelistDocumentationComponent ], imports: [ KomponentkartanModule, diff --git a/src/app/routes.ts b/src/app/routes.ts index b537a85d..83b9120f 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -43,6 +43,7 @@ import { NotificationDocumentationComponent } from './notification-documentation import { TabButtonComponent } from './tab-button/tab-button.component'; import { ComboboxDocumentationComponent } from './combobox-documentation/combobox-documentation.component'; import { EditableTableDocumentationComponent } from './editable-table-documentation/editable-table-documentation.component'; +import { SelectablelistDocumentationComponent } from './selectablelist-documentation/selectablelist-documentation.component'; export const appRoutes: Routes = [ @@ -96,7 +97,8 @@ export const appRoutes: Routes = [ { path: 'notification', component: NotificationDocumentationComponent }, { path: 'combobox', component: ComboboxDocumentationComponent }, { path: 'editable-table', component: EditableTableDocumentationComponent }, + { path: 'selectablelist', component: SelectablelistDocumentationComponent }, { path: '**', redirectTo: '/start' }, - + ]; diff --git a/src/app/selectablelist-documentation/selectablelist-documentation.component.html b/src/app/selectablelist-documentation/selectablelist-documentation.component.html new file mode 100644 index 00000000..26dc3f1a --- /dev/null +++ b/src/app/selectablelist-documentation/selectablelist-documentation.component.html @@ -0,0 +1,130 @@ + +
+

+ + <vgr-selectablelist> +

+
+

En tabell/lista där du kan markera ett alternativ och som markerar ditt valda alternativ i aktivt läge där möjligheten finns att sätta aktivt läge till true eller false. +

+
+

Förändras med tema: + ja +

+
+
+

Exempel

+
+ Lägg till fler rader + Ta bort rader +
+ + + Utbetalning avser + + Månad + Belopp + + + + {{justering.beskrivning}}... + + {{justering.period | date: 'MMM YYYY'}} + + {{justering.belopp}} + + + +
+

HTML

+
+ +
+
+

<vgr-selectablelist>

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
NamnBeskrivningExempel
id: stringAnvändaren kan sätta id på tabellen och bör göra det för att tabellen ska fungera korrekt.[id]="'id1'"
active: booleanTalar om huruvida tabellen är aktiverad eller inte. Default värde är false.[active]="true"
useScrollbar: booleanAnvändaren kan välja om man vill ha med en synlig scrollbar eller inte. Default värde är true.[useScrollbar]="false"
selectedChanged: EventEmitter<any>Event som triggas när man väljer en ny rad i tabellen(selectedChanged)="onSelectablelistChanged($event)"
+
+

<vgr-selectablelist-row>

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
NamnBeskrivningExempel
value: anyVärdet som ska skickas in när användaren klickar på raden eller väljer raden[value]="'i'"
groupheader: booleanTalar om huruvida raden är groupheader eller inte. Default värde är false.[groupheader]="false"
selectable: booleanTalar om huruvida raden är valbar eller inte. Default värde är true.[selectable]="true"
rowClicked : EventEmitter<any>Event som triggas när man klickar på en specifik rad i tabellen(rowClicked)="onRowClicked($event)"
+
+

<vgr-selectablelist-column>

+

<vgr-selectablelist-header-column>

+ + + + + + + + + + + + + + + + +
NamnBeskrivningExempel
alignRight: booleanSätter om värdet i kolumnen ska högerjusteras. Default värde är false.[alignRight]="true"
alignCenter: anySätter om värdet i kolumnen ska centreras. Default värde är false.[alignCenter]="true"
+
+
+

Navigering i tabell:

+
    +
  • - Tab/Shift+Tab - För att komma till tabellen
  • +
  • - Tab/Shift+Tab - För att komma ut ur tabellen om man är inne i tabellen
  • +
  • - Upp- och nerpil för att förflytta mellan raderna i tabellen.
  • +
+
+
diff --git a/src/app/selectablelist-documentation/selectablelist-documentation.component.scss b/src/app/selectablelist-documentation/selectablelist-documentation.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/selectablelist-documentation/selectablelist-documentation.component.ts b/src/app/selectablelist-documentation/selectablelist-documentation.component.ts new file mode 100644 index 00000000..0c8bd0c2 --- /dev/null +++ b/src/app/selectablelist-documentation/selectablelist-documentation.component.ts @@ -0,0 +1,109 @@ +import { Component, OnInit } from '@angular/core'; +import { HtmlEncodeService } from '../html-encode.service'; + +@Component({ + selector: 'vgr-selectablelist-documentation', + templateUrl: './selectablelist-documentation.component.html', + styleUrls: ['./selectablelist-documentation.component.scss'] +}) +export class SelectablelistDocumentationComponent implements OnInit { + + justeringar = [ + { + period: new Date('2022-09-29T13:41:49.407'), + belopp: 100, + beskrivning: 'En testbeskrivning' + }, + { + period: new Date('2023-09-29T13:41:49.407'), + belopp: 200, + beskrivning: 'En testbeskrivning2' + }, + { + period: new Date('2024-09-29T13:41:49.407'), + belopp: 300, + beskrivning: 'En testbeskrivning3' + }, + { + period: new Date('2025-09-29T13:41:49.407'), + belopp: 400, + beskrivning: 'En testbeskrivning4' + }, + { + period: new Date('2025-09-29T13:41:49.407'), + belopp: 400, + beskrivning: 'En testbeskrivning4' + }, + { + period: new Date('2025-09-29T13:41:49.407'), + belopp: 400, + beskrivning: 'En testbeskrivning4' + }, + { + period: new Date('2025-09-29T13:41:49.407'), + belopp: 400, + beskrivning: 'En testbeskrivning4' + }, + { + period: new Date('2025-09-29T13:41:49.407'), + belopp: 400, + beskrivning: 'En testbeskrivning4' + }, + { + period: new Date('2025-09-29T13:41:49.407'), + belopp: 400, + beskrivning: 'En testbeskrivning4' + }, + { + period: new Date('2025-09-29T13:41:49.407'), + belopp: 400, + beskrivning: 'En testbeskrivning4' + } + ] + + exampleCode = ` + + Utbetalning avser + + Månad + Belopp + + + + {{justering.beskrivning}}... + + {{justering.period | date: 'MMM YYYY'}} + + {{justering.belopp}} + + + `; + exampleCodeMarkup: string = ''; + + constructor(htmlEncoder: HtmlEncodeService) { + this.exampleCodeMarkup = + htmlEncoder.prepareHighlightedSection(this.exampleCode, 'HTML'); + } + + ngOnInit(): void { + } + + onSelectablelistChanged(event) { + console.log(event) + } + + addToList() { + const rowToAdd = + { + period: new Date('2026-09-29T13:41:49.407'), + belopp: 400, + beskrivning: 'En testbeskrivning4' + }; + this.justeringar.push(rowToAdd) + } + + removeFromList() { + this.justeringar.pop(); + } + +}