-
+
diff --git a/resources/js/components/ui/Listing/Search.vue b/resources/js/components/ui/Listing/Search.vue
index 3bae2bbcac4..66d33110d73 100644
--- a/resources/js/components/ui/Listing/Search.vue
+++ b/resources/js/components/ui/Listing/Search.vue
@@ -2,8 +2,14 @@
import { injectListingContext } from '../Listing/Listing.vue';
import { Input } from '@ui';
import debounce from '@/util/debounce.js';
-import { useTemplateRef } from 'vue';
+import { useId, useTemplateRef } from 'vue';
+defineProps({
+ /** Accessible label for the search field. Pass the listing's own noun, e.g. "Search assets". */
+ label: { type: String, default: null },
+});
+
+const id = useId();
const { activeFilterBadgeCount, searchQuery, setSearchQuery, reorderable } = injectListingContext();
const searchQueryUpdated = debounce((value) => setSearchQuery(value), 300);
@@ -15,12 +21,12 @@ defineExpose({ focus });
-
+
{
scope="col"
>
+ {{ reorderable ? __('Reorder') : __('Select') }}
-
|
+
+ {{ __('Actions') }}
+ |
diff --git a/resources/js/tests/components/ListingSearch.test.js b/resources/js/tests/components/ListingSearch.test.js
new file mode 100644
index 00000000000..3bb8b9a8a9b
--- /dev/null
+++ b/resources/js/tests/components/ListingSearch.test.js
@@ -0,0 +1,59 @@
+import { mount } from '@vue/test-utils';
+import { expect, test } from 'vitest';
+import { h } from 'vue';
+import * as Globals from '@/bootstrap/globals';
+import Listing from '@/components/ui/Listing/Listing.vue';
+import Search from '@/components/ui/Listing/Search.vue';
+import TableHead from '@/components/ui/Listing/TableHead.vue';
+
+Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn]));
+
+window.Statamic = {
+ $config: { get: () => undefined },
+ $progress: { loading: () => {}, complete: () => {} },
+ $preferences: { get: () => undefined },
+ $events: { $on: () => {}, $off: () => {}, $emit: () => {} },
+};
+
+const mountInListing = (child, listingProps = {}) =>
+ mount(Listing, { props: { items: [], ...listingProps }, slots: { default: () => child } });
+
+test('the search label defaults to a listing-agnostic string', () => {
+ const wrapper = mountInListing(h(Search));
+
+ expect(wrapper.find('label').text()).toBe('Search');
+});
+
+test('the search label can be set per listing', () => {
+ const wrapper = mountInListing(h(Search, { label: 'Search assets' }));
+
+ expect(wrapper.find('label').text()).toBe('Search assets');
+});
+
+test('the search label is associated with the input', () => {
+ const wrapper = mountInListing(h(Search));
+ const id = wrapper.find('input').attributes('id');
+
+ expect(id).toBeTruthy();
+ expect(wrapper.find('label').attributes('for')).toBe(id);
+});
+
+test('two search fields in the same listing do not share an id', () => {
+ const wrapper = mountInListing(h('div', [h(Search), h(Search)]));
+ const [a, b] = wrapper.findAll('input').map((input) => input.attributes('id'));
+
+ expect(a).toBeTruthy();
+ expect(b).toBeTruthy();
+ expect(a).not.toBe(b);
+});
+
+test('the actions header cell has an accessible name', () => {
+ const wrapper = mountInListing(h(TableHead), {
+ columns: [{ field: 'title', label: 'Title' }],
+ actionUrl: '/cp/collections/pages/actions',
+ });
+ const actionsHeader = wrapper.find('th.actions-column');
+
+ expect(actionsHeader.exists()).toBe(true);
+ expect(actionsHeader.text()).toBe('Actions');
+});