From 71a09bcf6eff97ae2017510d30bef8db37406d3c Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Fri, 17 Feb 2023 15:05:28 +0000
Subject: [PATCH 1/3] Started accessible controls for shelf book sort

Added buttons and fit to design.
Added new icon variations to support.
Extracted book item to own view and setup for future auto sorts.
---
 app/Http/Controllers/BookshelfController.php  |  4 +-
 resources/icons/add-small.svg                 |  1 +
 resources/icons/remove.svg                    |  1 +
 resources/sass/_components.scss               | 77 +++++++++++++++++++
 resources/sass/styles.scss                    | 65 ----------------
 resources/views/shelves/parts/form.blade.php  | 24 ++----
 .../parts/shelf-sort-book-item.blade.php      | 18 +++++
 7 files changed, 107 insertions(+), 83 deletions(-)
 create mode 100644 resources/icons/add-small.svg
 create mode 100644 resources/icons/remove.svg
 create mode 100644 resources/views/shelves/parts/shelf-sort-book-item.blade.php

diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php
index 537ea915b..d4642be78 100644
--- a/app/Http/Controllers/BookshelfController.php
+++ b/app/Http/Controllers/BookshelfController.php
@@ -64,7 +64,7 @@ class BookshelfController extends Controller
     public function create()
     {
         $this->checkPermission('bookshelf-create-all');
-        $books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug']);
+        $books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
         $this->setPageTitle(trans('entities.shelves_create'));
 
         return view('shelves.create', ['books' => $books]);
@@ -140,7 +140,7 @@ class BookshelfController extends Controller
         $this->checkOwnablePermission('bookshelf-update', $shelf);
 
         $shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
-        $books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug']);
+        $books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
 
         $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
 
diff --git a/resources/icons/add-small.svg b/resources/icons/add-small.svg
new file mode 100644
index 000000000..81aaf4f65
--- /dev/null
+++ b/resources/icons/add-small.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 13.114h-4.886V18h-2.228v-4.886H6v-2.228h4.886V6h2.228v4.886H18Z" style="stroke-width:.857143"/></svg>
\ No newline at end of file
diff --git a/resources/icons/remove.svg b/resources/icons/remove.svg
new file mode 100644
index 000000000..088c34a5d
--- /dev/null
+++ b/resources/icons/remove.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17.5 7.91 16.09 6.5 12 10.59 7.91 6.5 6.5 7.91 10.59 12 6.5 16.09l1.41 1.41L12 13.41l4.09 4.09 1.41-1.41L13.41 12Z"/></svg>
\ No newline at end of file
diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index 2150f6d07..ac0d913aa 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -1050,4 +1050,81 @@ $btt-size: 40px;
     vertical-align: top;
     line-height: 2;
   }
+}
+
+// Sortable scroll boxes
+.scroll-box {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+  max-height: 250px;
+  overflow-y: scroll;
+  border: 1px solid;
+  @include lightDark(border-color, #DDD, #000);
+  border-radius: 3px;
+  min-height: 20px;
+  @include lightDark(background-color, #EEE, #000);
+}
+.scroll-box-item {
+  border-bottom: 1px solid;
+  border-top: 1px solid;
+  @include lightDark(border-color, #DDD, #000);
+  margin-top: -1px;
+  @include lightDark(background-color, #FFF, #222);
+  display: flex;
+  align-items: flex-start;
+  padding: 1px;
+  &:last-child {
+    border-bottom: 0;
+  }
+  &:hover {
+    cursor: pointer;
+    @include lightDark(background-color, #f8f8f8, #333);
+  }
+  .handle {
+    color: #AAA;
+    cursor: grab;
+  }
+  .handle svg {
+    margin: 0;
+  }
+  > * {
+    padding: $-xs $-m;
+  }
+  .handle + * {
+    padding-left: 0;
+  }
+  &:hover .handle {
+    @include lightDark(color, #444, #FFF);
+  }
+  a:hover {
+    text-decoration: none;
+  }
+}
+
+input.scroll-box-search, .scroll-box-header-item {
+  font-size: 0.8rem;
+  padding: $-xs $-m;
+  border: 1px solid;
+  @include lightDark(border-color, #DDD, #000);
+  @include lightDark(background-color, #FFF, #222);
+  margin-bottom: -1px;
+  border-radius: 3px 3px 0 0;
+  width: 100%;
+  max-width: 100%;
+  height: auto;
+  line-height: 1.4;
+  color: #666;
+}
+
+.scroll-box-search + .scroll-box,
+.scroll-box-header-item + .scroll-box {
+  border-radius: 0 0 3px 3px;
+}
+
+.scroll-box[refs="shelf-sort@shelf-book-list"] [data-action="add"] {
+  display: none;
+}
+.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"] {
+  display: none;
 }
\ No newline at end of file
diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss
index 668cb5c85..0f4ec7041 100644
--- a/resources/sass/styles.scss
+++ b/resources/sass/styles.scss
@@ -198,71 +198,6 @@ $loadingSize: 10px;
   }
 }
 
-.scroll-box {
-  max-height: 250px;
-  overflow-y: scroll;
-  border: 1px solid;
-  @include lightDark(border-color, #DDD, #000);
-  border-radius: 3px;
-  min-height: 20px;
-  @include lightDark(background-color, #EEE, #000);
-}
-.scroll-box-item {
-  border-bottom: 1px solid;
-  border-top: 1px solid;
-  @include lightDark(border-color, #DDD, #000);
-  margin-top: -1px;
-  @include lightDark(background-color, #FFF, #222);
-  display: flex;
-  padding: 1px;
-  &:last-child {
-    border-bottom: 0;
-  }
-  &:hover {
-    cursor: pointer;
-    @include lightDark(background-color, #f8f8f8, #333);
-  }
-  .handle {
-    color: #AAA;
-    cursor: grab;
-  }
-  .handle svg {
-    margin: 0;
-  }
-  > * {
-    padding: $-xs $-m;
-  }
-  .handle + * {
-    padding-left: 0;
-  }
-  &:hover .handle {
-    @include lightDark(color, #444, #FFF);
-  }
-  a:hover {
-    text-decoration: none;
-  }
-}
-
-input.scroll-box-search, .scroll-box-header-item {
-  font-size: 0.8rem;
-  padding: $-xs $-m;
-  border: 1px solid;
-  @include lightDark(border-color, #DDD, #000);
-  @include lightDark(background-color, #FFF, #222);
-  margin-bottom: -1px;
-  border-radius: 3px 3px 0 0;
-  width: 100%;
-  max-width: 100%;
-  height: auto;
-  line-height: 1.4;
-  color: #666;
-}
-
-.scroll-box-search + .scroll-box,
-.scroll-box-header-item + .scroll-box {
-  border-radius: 0 0 3px 3px;
-}
-
 .fullscreen {
   border:0;
   position:fixed;
diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php
index 364f8e0be..02cea64ff 100644
--- a/resources/views/shelves/parts/form.blade.php
+++ b/resources/views/shelves/parts/form.blade.php
@@ -16,28 +16,20 @@
         <input refs="shelf-sort@input" type="hidden" name="books"
                value="{{ isset($shelf) ? $shelf->visibleBooks->implode('id', ',') : '' }}">
         <div class="scroll-box-header-item">{{ trans('entities.shelves_drag_books') }}</div>
-        <div refs="shelf-sort@shelf-book-list" class="scroll-box">
-            @if (count($shelf->visibleBooks ?? []) > 0)
-                @foreach ($shelf->visibleBooks as $book)
-                    <div data-id="{{ $book->id }}" class="scroll-box-item">
-                        <div class="handle">@icon('grip')</div>
-                        <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
-                    </div>
-                @endforeach
-            @endif
-        </div>
+        <ul refs="shelf-sort@shelf-book-list" class="scroll-box">
+            @foreach (($shelf->visibleBooks ?? []) as $book)
+                @include('shelves.parts.shelf-sort-book-item', ['book' => $book])
+            @endforeach
+        </ul>
     </div>
     <div class="form-group">
         <label for="books">{{ trans('entities.shelves_add_books') }}</label>
         <input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}">
-        <div refs="shelf-sort@all-book-list" class="scroll-box">
+        <ul refs="shelf-sort@all-book-list" class="scroll-box">
             @foreach ($books as $book)
-                <div data-id="{{ $book->id }}" class="scroll-box-item">
-                    <div class="handle">@icon('grip')</div>
-                    <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
-                </div>
+                @include('shelves.parts.shelf-sort-book-item', ['book' => $book])
             @endforeach
-        </div>
+        </ul>
     </div>
 </div>
 
diff --git a/resources/views/shelves/parts/shelf-sort-book-item.blade.php b/resources/views/shelves/parts/shelf-sort-book-item.blade.php
new file mode 100644
index 000000000..25aeecdb8
--- /dev/null
+++ b/resources/views/shelves/parts/shelf-sort-book-item.blade.php
@@ -0,0 +1,18 @@
+<li data-id="{{ $book->id }}"
+     data-name="{{ $book->name }}"
+     data-created="{{ $book->created_at->timestamp }}"
+     data-updated="{{ $book->updated_at->timestamp }}"
+     class="scroll-box-item">
+    <div class="handle px-s">@icon('grip')</div>
+    <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
+    <div class="buttons flex-container-row items-center ml-auto px-xxs py-xs">
+        <button type="button" data-action="move_up" class="icon-button p-xxs"
+                title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button>
+        <button type="button" data-action="move_down" class="icon-button p-xxs"
+                title="{{ trans('entities.books_sort_move_down') }}">@icon('chevron-down')</button>
+        <button type="button" data-action="remove" class="icon-button p-xxs"
+                title="{{ trans('common.remove') }}">@icon('remove')</button>
+        <button type="button" data-action="add" class="icon-button p-xxs"
+                title="{{ trans('common.add') }}">@icon('add-small')</button>
+    </div>
+</li>
\ No newline at end of file

From 9c26ccf43da7674f1e137737193a4ec9fa93d84d Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Fri, 17 Feb 2023 15:53:24 +0000
Subject: [PATCH 2/3] Added shelf book item sort action functionality

Adds JS logic, and dropdown action list, for quick-sorting the book
shelf list in addition to handling the book item action buttons.
---
 resources/js/components/shelf-sort.js        | 81 +++++++++++++++++---
 resources/sass/_components.scss              |  8 +-
 resources/views/shelves/parts/form.blade.php | 19 ++++-
 3 files changed, 92 insertions(+), 16 deletions(-)

diff --git a/resources/js/components/shelf-sort.js b/resources/js/components/shelf-sort.js
index d10470bd7..e4aefc591 100644
--- a/resources/js/components/shelf-sort.js
+++ b/resources/js/components/shelf-sort.js
@@ -1,6 +1,30 @@
 import Sortable from "sortablejs";
 import {Component} from "./component";
 
+/**
+ * @type {Object<string, function(HTMLElement, HTMLElement, HTMLElement)>}
+ */
+const itemActions = {
+    move_up(item, shelfBooksList, allBooksList) {
+        const list = item.parentNode;
+        const index = Array.from(list.children).indexOf(item);
+        const newIndex = Math.max(index - 1, 0);
+        list.insertBefore(item, list.children[newIndex] || null);
+    },
+    move_down(item, shelfBooksList, allBooksList) {
+        const list = item.parentNode;
+        const index = Array.from(list.children).indexOf(item);
+        const newIndex = Math.min(index + 2, list.children.length);
+        list.insertBefore(item, list.children[newIndex] || null);
+    },
+    remove(item, shelfBooksList, allBooksList) {
+        allBooksList.appendChild(item);
+    },
+    add(item, shelfBooksList, allBooksList) {
+        shelfBooksList.appendChild(item);
+    },
+};
+
 export class ShelfSort extends Component {
 
     setup() {
@@ -9,6 +33,9 @@ export class ShelfSort extends Component {
         this.shelfBookList = this.$refs.shelfBookList;
         this.allBookList = this.$refs.allBookList;
         this.bookSearchInput = this.$refs.bookSearch;
+        this.sortButtonContainer = this.$refs.sortButtonContainer;
+
+        this.lastSort = null;
 
         this.initSortable();
         this.setupListeners();
@@ -29,16 +56,22 @@ export class ShelfSort extends Component {
 
     setupListeners() {
         this.elem.addEventListener('click', event => {
-            const sortItem = event.target.closest('.scroll-box-item');
-            if (sortItem) {
-                event.preventDefault();
-                this.sortItemClick(sortItem);
+            const sortItemAction = event.target.closest('.scroll-box-item button[data-action]');
+            if (sortItemAction) {
+                this.sortItemActionClick(sortItemAction);
             }
         });
 
         this.bookSearchInput.addEventListener('input', event => {
             this.filterBooksByName(this.bookSearchInput.value);
         });
+
+        this.sortButtonContainer.addEventListener('click' , event => {
+            const button = event.target.closest('button[data-sort]');
+            if (button) {
+                this.sortShelfBooks(button.dataset.sort);
+            }
+        });
     }
 
     /**
@@ -62,15 +95,16 @@ export class ShelfSort extends Component {
     }
 
     /**
-     * Called when a sort item is clicked.
-     * @param {Element} sortItem
+     * Called when a sort item action button is clicked.
+     * @param {HTMLElement} sortItemAction
      */
-    sortItemClick(sortItem) {
-        const lists = this.elem.querySelectorAll('.scroll-box');
-        const newList = Array.from(lists).filter(list => sortItem.parentElement !== list);
-        if (newList.length > 0) {
-            newList[0].appendChild(sortItem);
-        }
+    sortItemActionClick(sortItemAction) {
+        const sortItem = sortItemAction.closest('.scroll-box-item');
+        const action = sortItemAction.dataset.action;
+
+        const actionFunction = itemActions[action];
+        actionFunction(sortItem, this.shelfBookList, this.allBookList);
+
         this.onChange();
     }
 
@@ -79,4 +113,27 @@ export class ShelfSort extends Component {
         this.input.value = shelfBookElems.map(elem => elem.getAttribute('data-id')).join(',');
     }
 
+    sortShelfBooks(sortProperty) {
+        const books = Array.from(this.shelfBookList.children);
+        const reverse = sortProperty === this.lastSort;
+
+        books.sort((bookA, bookB) => {
+            const aProp = bookA.dataset[sortProperty].toLowerCase();
+            const bProp = bookB.dataset[sortProperty].toLowerCase();
+
+            if (reverse) {
+                return aProp < bProp ? (aProp === bProp ? 0 : 1) : -1;
+            }
+
+            return aProp < bProp ? (aProp === bProp ? 0 : -1) : 1;
+        });
+
+        for (const book of books) {
+            this.shelfBookList.append(book);
+        }
+
+        this.lastSort = (this.lastSort === sortProperty) ? null : sortProperty;
+        this.onChange();
+    }
+
 }
\ No newline at end of file
diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index ac0d913aa..c86c04d33 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -1057,7 +1057,7 @@ $btt-size: 40px;
   list-style: none;
   padding: 0;
   margin: 0;
-  max-height: 250px;
+  max-height: 280px;
   overflow-y: scroll;
   border: 1px solid;
   @include lightDark(border-color, #DDD, #000);
@@ -1104,7 +1104,6 @@ $btt-size: 40px;
 
 input.scroll-box-search, .scroll-box-header-item {
   font-size: 0.8rem;
-  padding: $-xs $-m;
   border: 1px solid;
   @include lightDark(border-color, #DDD, #000);
   @include lightDark(background-color, #FFF, #222);
@@ -1125,6 +1124,9 @@ input.scroll-box-search, .scroll-box-header-item {
 .scroll-box[refs="shelf-sort@shelf-book-list"] [data-action="add"] {
   display: none;
 }
-.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"] {
+.scroll-box[refs="shelf-sort@all-book-list"] [data-action="remove"],
+.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_up"],
+.scroll-box[refs="shelf-sort@all-book-list"] [data-action="move_down"],
+{
   display: none;
 }
\ No newline at end of file
diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php
index 02cea64ff..4598cbacc 100644
--- a/resources/views/shelves/parts/form.blade.php
+++ b/resources/views/shelves/parts/form.blade.php
@@ -15,7 +15,24 @@
         <label for="books">{{ trans('entities.shelves_books') }}</label>
         <input refs="shelf-sort@input" type="hidden" name="books"
                value="{{ isset($shelf) ? $shelf->visibleBooks->implode('id', ',') : '' }}">
-        <div class="scroll-box-header-item">{{ trans('entities.shelves_drag_books') }}</div>
+        <div class="scroll-box-header-item flex-container-row items-center py-xs">
+            <span class="px-m py-xs">{{ trans('entities.shelves_drag_books') }}</span>
+            <div class="dropdown-container ml-auto" component="dropdown">
+                <button refs="dropdown@toggle"
+                        type="button"
+                        title="{{ trans('common.more') }}"
+                        class="icon-button px-xs py-xxs mx-xs text-bigger"
+                        aria-haspopup="true"
+                        aria-expanded="false">
+                    @icon('more')
+                </button>
+                <div refs="dropdown@menu shelf-sort@sort-button-container" class="dropdown-menu" role="menu">
+                    <button type="button" class="text-item" data-sort="name">{{ trans('entities.books_sort_name') }}</button>
+                    <button type="button" class="text-item" data-sort="created">{{ trans('entities.books_sort_created') }}</button>
+                    <button type="button" class="text-item" data-sort="updated">{{ trans('entities.books_sort_updated') }}</button>
+                </div>
+            </div>
+        </div>
         <ul refs="shelf-sort@shelf-book-list" class="scroll-box">
             @foreach (($shelf->visibleBooks ?? []) as $book)
                 @include('shelves.parts.shelf-sort-book-item', ['book' => $book])

From f799c9b260e7b0edf98fdf3f40c72199a0537d7f Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Fri, 17 Feb 2023 16:18:24 +0000
Subject: [PATCH 3/3] Applied shelf book sort changes from testing

Added better labelling of sort lists for screen readers.
Fadded out sort-item action buttons until hovering for a cleaner look.
---
 resources/sass/_components.scss                      |  6 ++++++
 resources/views/shelves/parts/form.blade.php         | 12 ++++++++----
 .../shelves/parts/shelf-sort-book-item.blade.php     |  2 +-
 3 files changed, 15 insertions(+), 5 deletions(-)

diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index c86c04d33..825501364 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -1085,6 +1085,9 @@ $btt-size: 40px;
     color: #AAA;
     cursor: grab;
   }
+  button {
+    opacity: .6;
+  }
   .handle svg {
     margin: 0;
   }
@@ -1097,6 +1100,9 @@ $btt-size: 40px;
   &:hover .handle {
     @include lightDark(color, #444, #FFF);
   }
+  &:hover button {
+    opacity: 1;
+  }
   a:hover {
     text-decoration: none;
   }
diff --git a/resources/views/shelves/parts/form.blade.php b/resources/views/shelves/parts/form.blade.php
index 4598cbacc..ad67cb85c 100644
--- a/resources/views/shelves/parts/form.blade.php
+++ b/resources/views/shelves/parts/form.blade.php
@@ -12,7 +12,7 @@
 
 <div component="shelf-sort" class="grid half gap-xl">
     <div class="form-group">
-        <label for="books">{{ trans('entities.shelves_books') }}</label>
+        <label for="books" id="shelf-sort-books-label">{{ trans('entities.shelves_books') }}</label>
         <input refs="shelf-sort@input" type="hidden" name="books"
                value="{{ isset($shelf) ? $shelf->visibleBooks->implode('id', ',') : '' }}">
         <div class="scroll-box-header-item flex-container-row items-center py-xs">
@@ -33,16 +33,20 @@
                 </div>
             </div>
         </div>
-        <ul refs="shelf-sort@shelf-book-list" class="scroll-box">
+        <ul refs="shelf-sort@shelf-book-list"
+            aria-labelledby="shelf-sort-books-label"
+            class="scroll-box">
             @foreach (($shelf->visibleBooks ?? []) as $book)
                 @include('shelves.parts.shelf-sort-book-item', ['book' => $book])
             @endforeach
         </ul>
     </div>
     <div class="form-group">
-        <label for="books">{{ trans('entities.shelves_add_books') }}</label>
+        <label for="books" id="shelf-sort-all-books-label">{{ trans('entities.shelves_add_books') }}</label>
         <input type="text" refs="shelf-sort@book-search" class="scroll-box-search" placeholder="{{ trans('common.search') }}">
-        <ul refs="shelf-sort@all-book-list" class="scroll-box">
+        <ul refs="shelf-sort@all-book-list"
+            aria-labelledby="shelf-sort-all-books-label"
+            class="scroll-box">
             @foreach ($books as $book)
                 @include('shelves.parts.shelf-sort-book-item', ['book' => $book])
             @endforeach
diff --git a/resources/views/shelves/parts/shelf-sort-book-item.blade.php b/resources/views/shelves/parts/shelf-sort-book-item.blade.php
index 25aeecdb8..795aee7be 100644
--- a/resources/views/shelves/parts/shelf-sort-book-item.blade.php
+++ b/resources/views/shelves/parts/shelf-sort-book-item.blade.php
@@ -4,7 +4,7 @@
      data-updated="{{ $book->updated_at->timestamp }}"
      class="scroll-box-item">
     <div class="handle px-s">@icon('grip')</div>
-    <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
+    <div class="text-book">@icon('book'){{ $book->name }}</div>
     <div class="buttons flex-container-row items-center ml-auto px-xxs py-xs">
         <button type="button" data-action="move_up" class="icon-button p-xxs"
                 title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button>