diff --git a/config/cp.php b/config/cp.php index 090e6e8b049..485b5c946a5 100644 --- a/config/cp.php +++ b/config/cp.php @@ -70,6 +70,19 @@ 'pagination_size_options' => [10, 25, 50, 100], + /* + |-------------------------------------------------------------------------- + | Select All Limit + |-------------------------------------------------------------------------- + | + | The maximum number of matching results that can be selected across pages + | via "Select all". Raise this if your server can handle larger bulk + | actions, or set it to null for no limit. + | + */ + + 'select_all_limit' => 1000, + /* |-------------------------------------------------------------------------- | Links to Documentation diff --git a/lang/de.json b/lang/de.json index 7a7f6a6387e..113d8bf76f2 100644 --- a/lang/de.json +++ b/lang/de.json @@ -384,9 +384,9 @@ "Delete|Delete :count items?": "Löschen|:count Elemente löschen?", "Descending": "Absteigend", "Description of the image": "Bildbeschreibung", - "Deselect :count item|Deselect all :count items": ":count Element abwählen|Alle :count Elemente abwählen", + "Deselect :count item|Deselect :count items": ":count Element abwählen|:count Elemente abwählen", "Deselect :title": ":title abwählen", - "Deselect all items": "Alle Elemente abwählen", + "Deselect items": "Elemente abwählen", "Detach": "Trennen", "Details": "Details", "Dictionary": "Wörterbuch", @@ -1001,7 +1001,7 @@ "Select": "Auswählen", "Select :title": ":title auswählen", "Select Across Sites": "Seitenübergreifende Auswahl", - "Select all items": "Alle Elemente auswählen", + "Select items": "Elemente auswählen", "Select aspect ratio": "Seitenverhältnis wählen", "Select Collection(s)": "Sammlung(en) auswählen", "Select date": "Datum auswählen", diff --git a/lang/de_CH.json b/lang/de_CH.json index 8d35bcebe1f..9cb7adddf29 100644 --- a/lang/de_CH.json +++ b/lang/de_CH.json @@ -384,9 +384,9 @@ "Delete|Delete :count items?": "Löschen|:count Elemente löschen?", "Descending": "Absteigend", "Description of the image": "Bildbeschreibung", - "Deselect :count item|Deselect all :count items": ":count Element abwählen|Alle :count Elemente abwählen", + "Deselect :count item|Deselect :count items": ":count Element abwählen|:count Elemente abwählen", "Deselect :title": ":title abwählen", - "Deselect all items": "Alle Elemente abwählen", + "Deselect items": "Elemente abwählen", "Detach": "Trennen", "Details": "Details", "Dictionary": "Wörterbuch", @@ -1001,7 +1001,7 @@ "Select": "Auswählen", "Select :title": ":title auswählen", "Select Across Sites": "Seitenübergreifende Auswahl", - "Select all items": "Alle Elemente auswählen", + "Select items": "Elemente auswählen", "Select aspect ratio": "Seitenverhältnis wählen", "Select Collection(s)": "Sammlung(en) auswählen", "Select date": "Datum auswählen", diff --git a/lang/en/messages.php b/lang/en/messages.php index 5899880f2c1..638aaafd67e 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -231,12 +231,13 @@ 'role_intro' => 'Roles are groups of access and action permissions that can be assigned to users and user groups.', 'role_title_instructions' => 'Use a singular noun, such as \'Editor\' or \'Admin\'', 'search_utility_description' => 'Manage and view important information about Statamic\'s search indexes.', - 'selections_click_to_deselect_all' => 'All :total items selected. Uncheck to deselect all items.', - 'selections_click_to_select_all' => 'No items selected. Check to select all :total items.', + 'selections_click_to_deselect_all' => 'All :total items selected. Uncheck to deselect items.', + 'selections_click_to_select_all' => 'No items selected. Check to select :total items.', 'selections_item_selected' => ':title is currently selected. Click to deselect.', 'selections_item_unselected' => ':title is not selected. Click to select.', 'selections_limit_reached' => 'Selection limit reached. Cannot select :title', - 'selections_select_all' => ':selected of :total items selected. Check to select all items.', + 'selections_select_all' => ':selected of :total items selected. Check to select items.', + 'selections_select_all_matching' => 'Select :total items across all pages', 'session_expiry_dismissed_banner' => 'Your session is about to expire. Click here to extend it and stay signed in.', 'session_expiry_dismissed_login_banner' => 'Your session has expired. Click here to log back in.', 'session_expiry_enter_password' => 'Enter your password to continue.', diff --git a/lang/fr.json b/lang/fr.json index bc6fc3342c2..1cf507b9e73 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -383,9 +383,9 @@ "Delete|Delete :count items?": "Supprimer|Supprimer :count éléments ?", "Descending": "Décroissant", "Description of the image": "Description de l'image", - "Deselect :count item|Deselect all :count items": "Désélectionner :count élément|Désélectionner les :count éléments", + "Deselect :count item|Deselect :count items": "Désélectionner :count élément|Désélectionner :count éléments", "Deselect :title": "Désélectionner :title", - "Deselect all items": "Désélectionner tous les éléments", + "Deselect items": "Désélectionner les éléments", "Detach": "Détacher", "Details": "Détails", "Dictionary": "Dictionnaire", @@ -1001,7 +1001,7 @@ "Select": "Choisir", "Select :title": "Choisir :title", "Select Across Sites": "Choisir au sein des sites", - "Select all items": "Choisir tous les éléments", + "Select items": "Choisir les éléments", "Select aspect ratio": "Sélectionner le ratio d'aspect", "Select Collection(s)": "Choisir collection(s)", "Select date": "Choisir la date", diff --git a/lang/nb.json b/lang/nb.json index 0d5602792cb..7b8f2717b37 100644 --- a/lang/nb.json +++ b/lang/nb.json @@ -345,7 +345,7 @@ "Delete|Delete :count items?": "Slette|Slette :count oppføringer?", "Descending": "Synkende", "Description of the image": "Beskrivelse av bildet", - "Deselect :count item|Deselect all :count items": "Fjern markering på :count element|Fjern markering på :count elementer", + "Deselect :count item|Deselect :count items": "Fjern markering på :count element|Fjern markering på :count elementer", "Deselect option": "Fjern valg", "Detach": "Koble fra", "Dictionary": "Ordbok", diff --git a/lang/nl.json b/lang/nl.json index 01a7c751276..b600ea2dc53 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -370,9 +370,9 @@ "Delete|Delete :count items?": "Verwijder|Verwijder :count items?", "Descending": "Aflopend", "Description of the image": "Omschrijving van de afbeelding", - "Deselect :count item|Deselect all :count items": "Deselecteer :count item|Deselecteer alle :count items", + "Deselect :count item|Deselect :count items": "Deselecteer :count item|Deselecteer :count items", "Deselect :title": "Deselecteer :title", - "Deselect all items": "Deselecteer alle items", + "Deselect items": "Deselecteer items", "Deselect option": "Deselecteer optie", "Detach": "Losmaken", "Details": "Details", @@ -958,7 +958,7 @@ "Select": "Selecteer", "Select :title": "Selecteer :title", "Select Across Sites": "Selecteer over sites heen", - "Select all items": "Selecteer alle items", + "Select items": "Selecteer items", "Select aspect ratio": "Selecteer beeldverhouding", "Select Collection(s)": "Selecteer collectie(s)", "Select date": "Selecteer datum", diff --git a/lang/ru.json b/lang/ru.json index 2833d06d0d9..5f8f7794b19 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -387,9 +387,9 @@ "Delete|Delete :count items?": "Удалить|Удалить :count элемента?|Удалить :count элементов?", "Descending": "По убыванию", "Description of the image": "Описание изображения", - "Deselect :count item|Deselect all :count items": "Снять выделение с :count элемента|Снять выделение со всех :count элементов", + "Deselect :count item|Deselect :count items": "Снять выделение с :count элемента|Снять выделение с :count элементов", "Deselect :title": "Снять выделение с :title", - "Deselect all items": "Снять выделение со всех элементов", + "Deselect items": "Снять выделение с элементов", "Deselect option": "Отменить выбор", "Detach": "Отсоединить", "Details": "Детали", @@ -1018,7 +1018,7 @@ "Select": "Выбор", "Select :title": "Выбрать :title", "Select Across Sites": "Выбрать по всем сайтам", - "Select all items": "Выбрать все элементы", + "Select items": "Выбрать элементы", "Select asset container": "Выбрать контейнер ресурсов", "Select Collection(s)": "Выбрать коллекции", "Select date": "Выбрать дату", diff --git a/resources/js/components/assets/Browser/Browser.vue b/resources/js/components/assets/Browser/Browser.vue index 775094b79c3..796a7ecb974 100644 --- a/resources/js/components/assets/Browser/Browser.vue +++ b/resources/js/components/assets/Browser/Browser.vue @@ -26,6 +26,7 @@ :action-url="actionUrl" :action-context="actionContext" :allow-bulk-actions="allowBulkActions" + :allow-select-all-matching="false" :selections="selectedAssets" :max-selections="maxFiles" :preferences-prefix="preferencesPrefix" diff --git a/resources/js/components/ui/Checkbox/Item.vue b/resources/js/components/ui/Checkbox/Item.vue index d94b35ac9b0..394c042e9ef 100644 --- a/resources/js/components/ui/Checkbox/Item.vue +++ b/resources/js/components/ui/Checkbox/Item.vue @@ -88,12 +88,11 @@ const containerClasses = computed(() => { const conditionalProps = computed(() => { const props_obj = {}; - if (props.modelValue !== null) { - props_obj.modelValue = props.modelValue; - } - + // Reka CheckboxRoot uses modelValue === 'indeterminate' (no separate indeterminate prop). if (props.indeterminate) { - props_obj.indeterminate = true; + props_obj.modelValue = 'indeterminate'; + } else if (props.modelValue !== null) { + props_obj.modelValue = props.modelValue; } // Only add aria-describedby if description exists AND it's not a solo checkbox diff --git a/resources/js/components/ui/Listing/BulkActions.vue b/resources/js/components/ui/Listing/BulkActions.vue index 561130b35a2..6ecdcc6d1fb 100644 --- a/resources/js/components/ui/Listing/BulkActions.vue +++ b/resources/js/components/ui/Listing/BulkActions.vue @@ -2,11 +2,20 @@ import { Motion } from 'motion-v'; import { injectListingContext } from '../Listing/Listing.vue'; import { computed, ref, watch } from 'vue'; -import { Button, ButtonGroup } from '@ui'; import BulkActions from '@/components/actions/BulkActions.vue'; import BulkActionsFloatingToolbar from './BulkActionsFloatingToolbar.vue'; -const { actionUrl, actionContext, selections, refresh, clearSelections } = injectListingContext(); +const { + actionUrl, + actionContext, + selections, + refresh, + clearSelections, + canSelectAllMatching, + selectAllMatching, + selectingAllMatching, + meta, +} = injectListingContext(); const busy = ref(false); const hasSelections = computed(() => selections.value.length > 0); const visible = ref(false); @@ -59,6 +68,10 @@ function actionFailed(response) { :visible="visible" :selections="selections" :clear-selections="clearSelections" + :can-select-all-matching="canSelectAllMatching" + :select-all-matching="selectAllMatching" + :selecting-all-matching="selectingAllMatching" + :matching-total="meta?.total ?? 0" /> diff --git a/resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue b/resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue index 17e3d8f2f0a..f717fd94449 100644 --- a/resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue +++ b/resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue @@ -5,6 +5,7 @@ * Renders the floating toolbar when items are selected in a listing, with keyboard shortcuts * for each action. Shortcuts are derived from the localized action title (first unused letter). * Delete uses the backspace icon and is triggered by Delete/Backspace only. + * Select-all-matching uses A (always reserved so actions skip it). */ import { Motion } from 'motion-v'; import { computed, onMounted, onUnmounted } from 'vue'; @@ -14,18 +15,29 @@ import { Button, ButtonGroup, Icon } from '@ui'; const DESELECT_SHORTCUT_KEY = 'Escape'; const DESELECT_SHORTCUT_LABEL = 'Esc'; const DELETE_SHORTCUT_KEY = 'Delete'; +const SELECT_ALL_MATCHING_SHORTCUT_KEY = 'a'; +const SELECT_ALL_MATCHING_SHORTCUT_LABEL = 'A'; const shortcutKeyClasses = 'ms-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded bg-gray-200/75 px-1 font-semibold uppercase text-[0.625rem] text-gray-600 dark:bg-gray-800 dark:text-gray-400'; +const selectAllMatchingShortcutKeyClasses = + 'ms-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded border border-blue-300/80 px-1 font-semibold uppercase text-[0.625rem] text-blue-600 bg-blue-100/80 dark:border-blue-700 dark:text-blue-400 dark:bg-blue-950'; + const props = defineProps({ actions: { type: Array, default: () => [] }, visible: { type: Boolean, default: false }, selections: { type: Array, default: () => [] }, clearSelections: { type: Function, default: null }, + canSelectAllMatching: { type: Boolean, default: false }, + selectAllMatching: { type: Function, default: null }, + selectingAllMatching: { type: Boolean, default: false }, + matchingTotal: { type: Number, default: 0 }, }); const hasSelections = computed(() => (props.selections?.length ?? 0) > 0); +const showSelectAllMatching = computed(() => !!props.canSelectAllMatching); +const isSelectingAllMatching = computed(() => !!props.selectingAllMatching); /** True if this action is the built-in delete (by handle or trash icon). */ function isDeleteAction(action) { @@ -47,6 +59,9 @@ function findShortcutKey(action, used) { const actionsWithShortcuts = computed(() => { const used = new Set(); + // Always reserve A so action shortcuts stay stable whether the select-all row is visible. + used.add(SELECT_ALL_MATCHING_SHORTCUT_KEY); + return (props.actions || []).map((action) => { // Delete always shows the backspace icon and is triggered by Delete/Backspace only; no letter. if (isDeleteAction(action)) { @@ -95,6 +110,17 @@ function onKeydown(event) { if (event.metaKey || event.ctrlKey || event.altKey) return; const key = event.key?.length === 1 ? event.key.toLowerCase() : null; if (!key) return; + + if ( + key === SELECT_ALL_MATCHING_SHORTCUT_KEY && + showSelectAllMatching.value && + !isSelectingAllMatching.value + ) { + props.selectAllMatching?.(); + event.preventDefault(); + return; + } + const action = actionsWithShortcuts.value.find((a) => a.shortcutKey === key); if (action?.run) { action.run(); @@ -110,20 +136,40 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown, true));