Repository: primefaces/primefaces (java)

Issue:
SplitButton: Insufficient accessibility suitability, operation with screen reader not possible
### Description

During our accessibility assessment of our application the SplitButtons were criticised as a last problem.
Although normal usability via keyboard is completely fine, it is insufficient when screenreaders (JAWS or NVDA)
are used: 
It is possible to open the split button with the screenreader navigation, but it is impossible 
to navigate to the subelement buttons. Also the screenreaders do not properly read the elements and subelements of the split button.


### Describe the solution you would like

The testers analyzed the showcase:
https://www.primefaces.org/showcase/ui/button/splitButton.xhtml?jfwid=61a87
and gave some details and hints with a screenshot, which I attached.

![Image](https://github.com/user-attachments/assets/98c97008-2182-42f4-bee3-bf968725d60c)

The attribute „aria-haspopup“ is on the wrong html level. It should be moved to the affected interactive element, in
this case the second, highlighted <button> element in the screenshot, because this has the popup. It's value should not be "true" but "menu" for this situation.

The dropdown button has an improper name "ui-button". For usage with screenreader it should be adjustable, e.g. to contain a text like "more options", or "more actions."

The testers did not give us more recommendations how to finally fix the split button,
instead they pointed to an example how such a split button should be 
implemented to be accessibility compliant:
https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-links/

### Additional context

We have a workaround: 
We used standard buttons for the actions as we only grouped 2 or 3 buttons with the split button and we had enough space in our tool bar.

Pull request:
Fix #13538: 15.0.2 SplitButton aria menu button fixes
Fix #13538: 15.0.2 SplitButton aria menu button fixes

Patch:
diff --git a/primefaces-showcase/src/main/java/org/primefaces/showcase/view/input/CalendarJava8View.java b/primefaces-showcase/src/main/java/org/primefaces/showcase/view/input/CalendarJava8View.java
index 7ede301165..5df7f7ffea 100644
--- a/primefaces-showcase/src/main/java/org/primefaces/showcase/view/input/CalendarJava8View.java
+++ b/primefaces-showcase/src/main/java/org/primefaces/showcase/view/input/CalendarJava8View.java
@@ -139,6 +139,15 @@ public class CalendarJava8View implements Serializable {
         facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Date Selected", event.getObject().format(formatter)));
     }
 
+    public void onDateRangeSelect(SelectEvent<List<LocalDate>> event) {
+        FacesContext facesContext = FacesContext.getCurrentInstance();
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
+        String range = event.getObject().get(0).format(formatter)
+                + " - "
+                + event.getObject().get(event.getObject().size() - 1).format(formatter);
+        facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Range Selected", range));
+    }
+
     public void onDateTimeSelect(SelectEvent<LocalDateTime> event) {
         FacesContext facesContext = FacesContext.getCurrentInstance();
         DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
diff --git a/primefaces-showcase/src/main/webapp/ui/input/datepicker/datePickerJava8.xhtml b/primefaces-showcase/src/main/webapp/ui/input/datepicker/datePickerJava8.xhtml
index 4d7becb426..68c23ed3c8 100644
--- a/primefaces-showcase/src/main/webapp/ui/input/datepicker/datePickerJava8.xhtml
+++ b/primefaces-showcase/src/main/webapp/ui/input/datepicker/datePickerJava8.xhtml
@@ -70,7 +70,9 @@
 
                     <div class="field col-12 md:col-4">
                         <p:outputLabel for="@next" value="Week Picker" />
-                        <p:datePicker id="week" view="week" value="#{calendarJava8View.week}" />
+                        <p:datePicker id="week" view="week" value="#{calendarJava8View.week}">
+                            <p:ajax event="dateSelect" listener="#{calendarJava8View.onDateRangeSelect}" update="msgs" />
+                        </p:datePicker>
                     </div>
 
                     <div class="field col-12 md:col-4">
diff --git a/primefaces/src/main/java/org/primefaces/component/datepicker/DatePickerRenderer.java b/primefaces/src/main/java/org/primefaces/component/datepicker/DatePickerRenderer.java
index 260ec7e79a..d8e916d811 100644
--- a/primefaces/src/main/java/org/primefaces/component/datepicker/DatePickerRenderer.java
+++ b/primefaces/src/main/java/org/primefaces/component/datepicker/DatePickerRenderer.java
@@ -377,7 +377,8 @@ public class DatePickerRenderer extends BaseCalendarRenderer {
 
                 return multi;
             case "range":
-                List<String> rangeStr = CalendarUtils.splitRange(submittedValue, datePicker.calculatePattern(), datePicker.getRangeSeparator());
+                List<String> rangeStr = CalendarUtils.splitRange(submittedValue, datePicker.calculatePattern(),
+                        datePicker.getRangeSeparator(), "week".equals(datePicker.getView()));
                 List<Object> range = new ArrayList<>();
                 if (rangeStr.size() == 2) {
                     for (int i = 0; i < rangeStr.size(); i++) {
diff --git a/primefaces/src/main/java/org/primefaces/component/splitbutton/SplitButtonRenderer.java b/primefaces/src/main/java/org/primefaces/component/splitbutton/SplitButtonRenderer.java
index 6563fed247..909f79bce9 100644
--- a/primefaces/src/main/java/org/primefaces/component/splitbutton/SplitButtonRenderer.java
+++ b/primefaces/src/main/java/org/primefaces/component/splitbutton/SplitButtonRenderer.java
@@ -106,9 +106,6 @@ public class SplitButtonRenderer extends MenuItemAwareRenderer {
         writer.startElement("div", button);
         writer.writeAttribute("id", clientId, "id");
         writer.writeAttribute("class", styleClass, "id");
-        writer.writeAttribute(HTML.ARIA_HASPOPUP, Boolean.toString(hasOverlay), null);
-        writer.writeAttribute(HTML.ARIA_CONTROLS, menuId, null);
-        writer.writeAttribute(HTML.ARIA_EXPANDED, "false", null);
         if (button.getStyle() != null) {
             writer.writeAttribute("style", button.getStyle(), "id");
         }
@@ -116,7 +113,7 @@ public class SplitButtonRenderer extends MenuItemAwareRenderer {
         encodeDefaultButton(context, button, buttonId);
         OverlayPanel customOverlay = button.getCustomOverlay();
         if (customOverlay != null || button.getElementsCount() > 0) {
-            encodeMenuIcon(context, button, menuButtonId);
+            encodeMenuIcon(context, button, menuButtonId, menuId, hasOverlay);
             if (hasOverlay) {
                 encodeMenu(context, button, menuId);
             }
@@ -187,7 +184,7 @@ public class SplitButtonRenderer extends MenuItemAwareRenderer {
         writer.endElement("button");
     }
 
-    protected void encodeMenuIcon(FacesContext context, SplitButton button, String id) throws IOException {
+    protected void encodeMenuIcon(FacesContext context, SplitButton button, String id, String menuId, boolean hasOverlay) throws IOException {
         ResponseWriter writer = context.getResponseWriter();
         String buttonClass = SplitButton.MENU_ICON_BUTTON_CLASS;
         if (button.isDisabled()) {
@@ -199,6 +196,9 @@ public class SplitButtonRenderer extends MenuItemAwareRenderer {
         writer.writeAttribute("name", id, null);
         writer.writeAttribute("type", "button", null);
         writer.writeAttribute("class", buttonClass, null);
+        writer.writeAttribute(HTML.ARIA_HASPOPUP, hasOverlay ? "menu" : "false", null);
+        writer.writeAttribute(HTML.ARIA_CONTROLS, menuId, null);
+        writer.writeAttribute(HTML.ARIA_EXPANDED, "false", null);
 
         if (button.isDisabled()) {
             writer.writeAttribute("disabled", "disabled", "disabled");
diff --git a/primefaces/src/main/java/org/primefaces/util/CalendarUtils.java b/primefaces/src/main/java/org/primefaces/util/CalendarUtils.java
index 4b52098383..2162b226ab 100644
--- a/primefaces/src/main/java/org/primefaces/util/CalendarUtils.java
+++ b/primefaces/src/main/java/org/primefaces/util/CalendarUtils.java
@@ -41,7 +41,8 @@ import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.time.temporal.Temporal;
-import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import java.util.Locale;
@@ -637,19 +638,30 @@ public class CalendarUtils {
         return now;
     }
 
-    public static List<String> splitRange(final String dateRange, final String pattern, final String separator) {
-        final String token = Constants.SPACE + separator + Constants.SPACE;
-        if (pattern.contains(token)) {
-            throw new FacesException("Pattern '" + pattern + "' contains separator '" + token + "'");
+    public static List<String> splitRange(String input, String datePattern, String rangeSeparator,
+                                          boolean week) {
+
+        // security check
+        String token = Constants.SPACE + rangeSeparator + Constants.SPACE;
+        if (datePattern.contains(token)) {
+            throw new FacesException("Pattern '" + datePattern + "' contains separator '" + token + "'");
         }
-        final List<String> dates = new ArrayList<>();
-        if (!dateRange.contains(token)) {
-            return dates;
+
+        // fast return
+        if (!input.contains(token)) {
+            return Collections.emptyList();
         }
-        final int index = dateRange.indexOf(token);
-        dates.add(dateRange.substring(0, index));
-        dates.add(dateRange.substring(index + token.length()));
-        return dates;
+
+        int tokenIndex = input.indexOf(token);
+        String from = input.substring(0, tokenIndex);
+        String to = input.substring(tokenIndex + token.length());
+        if (week) {
+            // hardcoded pattern, see JS:
+            // formattedValue += ' (' + this.options.locale.weekHeader + ' ' + week + ')';
+            to = to.substring(0, to.lastIndexOf(" ("));
+        }
+
+        return Arrays.asList(from, to);
     }
 
 }
diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/core/core.js b/primefaces/src/main/resources/META-INF/resources/primefaces/core/core.js
index 12a7d2996d..db7eb5e2b3 100644
--- a/primefaces/src/main/resources/META-INF/resources/primefaces/core/core.js
+++ b/primefaces/src/main/resources/META-INF/resources/primefaces/core/core.js
@@ -1739,6 +1739,7 @@
                 "jumpToPageDropdownLabel": "Jump to Page Dropdown",
                 "jumpToPageInputLabel": "Jump to Page Input",
                 "lastPageLabel": "Last Page",
+                "listLabel": "Options List",
                 "listView": "List View",
                 "moveAllToSource": "Move All to Source",
                 "moveAllToTarget": "Move All to Target",
diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/forms/forms.selectoneradio.js b/primefaces/src/main/resources/META-INF/resources/primefaces/forms/forms.selectoneradio.js
index 50489d2105..5ed96965c0 100644
--- a/primefaces/src/main/resources/META-INF/resources/primefaces/forms/forms.selectoneradio.js
+++ b/primefaces/src/main/resources/META-INF/resources/primefaces/forms/forms.selectoneradio.js
@@ -171,7 +171,10 @@ PrimeFaces.widget.SelectOneRadio = PrimeFaces.widget.BaseWidget.extend({
                 else
                     radio = target.children('.ui-radiobutton-box'); //custom layout
 
-                radio.trigger('click.selectOneRadio');
+                // #13546 only trigger radio if its not already active
+                if (!radio.hasClass('ui-state-active')) {
+                    radio.trigger('click.selectOneRadio');
+                }
 
                 e.preventDefault();
             });
diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/forms/forms.splitbutton.js b/primefaces/src/main/resources/META-INF/resources/primefaces/forms/forms.splitbutton.js
index e0c6c8da67..f1187c7dbd 100644
--- a/primefaces/src/main/resources/META-INF/resources/primefaces/forms/forms.splitbutton.js
+++ b/primefaces/src/main/resources/META-INF/resources/primefaces/forms/forms.splitbutton.js
@@ -120,6 +120,7 @@ PrimeFaces.widget.SplitButton = PrimeFaces.widget.BaseWidget.extend({
         this.button.data('primefaces-overlay-target', true).find('*').data('primefaces-overlay-target', true);
 
         // toggle menu
+        this.menuButton.attr('aria-label', this.getAriaLabel('listLabel'));
         this.menuButton.on('click.splitbutton', function() {
             if(!$this.cfg.disabled && $this.menu.is(':hidden'))
                 $this.show();
@@ -487,7 +488,7 @@ PrimeFaces.widget.SplitButton = PrimeFaces.widget.BaseWidget.extend({
                 onEntered: function() {
                     $this.bindPanelEvents();
 
-                    $this.jq.attr('aria-expanded', true);
+                    $this.menuButton.attr('aria-expanded', true);
 
                     if ($this.cfg.filter && $this.cfg.filterInputAutoFocus) {
                         $this.filterInput.trigger('focus');
@@ -513,7 +514,7 @@ PrimeFaces.widget.SplitButton = PrimeFaces.widget.BaseWidget.extend({
                     $this.unbindPanelEvents();
                 },
                 onExited: function() {
-                    $this.jq.attr('aria-expanded', false);
+                    $this.menuButton.attr('aria-expanded', false);
                     $this.menuitems.filter('.ui-state-hover').removeClass('ui-state-hover');
                     $this.menuButton.removeClass('ui-state-focus');
                 }
diff --git a/primefaces/src/main/resources/META-INF/resources/primefaces/locales/locale-en.js b/primefaces/src/main/resources/META-INF/resources/primefaces/locales/locale-en.js
index 6fa73b3254..64d3d9c538 100644
--- a/primefaces/src/main/resources/META-INF/resources/primefaces/locales/locale-en.js
+++ b/primefaces/src/main/resources/META-INF/resources/primefaces/locales/locale-en.js
@@ -87,6 +87,7 @@ if (window.PrimeFaces) {
       jumpToPageDropdownLabel: "Jump to Page Dropdown",
       jumpToPageInputLabel: "Jump to Page Input",
       lastPageLabel: "Last Page",
+      listLabel: "Options List",
       listView: "List View",
       moveAllToSource: "Move All to Source",
       moveAllToTarget: "Move All to Target",


Test patch:
diff --git a/primefaces/src/test/java/org/primefaces/util/CalendarUtilsTest.java b/primefaces/src/test/java/org/primefaces/util/CalendarUtilsTest.java
index 12f13be530..cff2f05e74 100644
--- a/primefaces/src/test/java/org/primefaces/util/CalendarUtilsTest.java
+++ b/primefaces/src/test/java/org/primefaces/util/CalendarUtilsTest.java
@@ -217,18 +217,33 @@ class CalendarUtilsTest {
 
     @Test
     void splitRange() {
-        List<String> splitRange = CalendarUtils.splitRange("2021-03-01 - 2021-03-31", "yyyy-MM-dd", "-");
+        List<String> splitRange = CalendarUtils.splitRange("2021-03-01 - 2021-03-31", "yyyy-MM-dd", "-", false);
         assertEquals(2, splitRange.size());
         assertEquals("2021-03-01", splitRange.get(0));
         assertEquals("2021-03-31", splitRange.get(1));
-        splitRange = CalendarUtils.splitRange("2021-03-01", "yyyy-MM-dd", "-");
+        splitRange = CalendarUtils.splitRange("2021-03-01", "yyyy-MM-dd", "-", false);
         assertTrue(splitRange.isEmpty());
-        splitRange = CalendarUtils.splitRange("", "yyyy-MM-dd", "-");
+        splitRange = CalendarUtils.splitRange("", "yyyy-MM-dd", "-", false);
         assertTrue(splitRange.isEmpty());
 
         assertThrows(FacesException.class, () -> {
-            CalendarUtils.splitRange("2021 - 03 - 01 - 2021 - 03 - 31", "yyyy - MM - dd", "-");
+            CalendarUtils.splitRange("2021 - 03 - 01 - 2021 - 03 - 31", "yyyy - MM - dd", "-", false);
         });
     }
 
+    @Test
+    void splitRangeWeek() {
+        List<String> splitRange = CalendarUtils.splitRange("2021-03-01 - 2021-03-31 (Wk 21)", "yyyy-MM-dd", "-", true);
+        assertEquals(2, splitRange.size());
+        assertEquals("2021-03-01", splitRange.get(0));
+        assertEquals("2021-03-31", splitRange.get(1));
+        splitRange = CalendarUtils.splitRange("2021-03-01 (Wk 21)", "yyyy-MM-dd", "-", true);
+        assertTrue(splitRange.isEmpty());
+        splitRange = CalendarUtils.splitRange("", "yyyy-MM-dd (Wk 21)", "-", true);
+        assertTrue(splitRange.isEmpty());
+
+        assertThrows(FacesException.class, () -> {
+            CalendarUtils.splitRange("2021 - 03 - 01 - 2021 - 03 - 31 (Wk 21)", "yyyy - MM - dd", "-", true);
+        });
+    }
 }