@@ -55,6 +55,27 @@ function clickItem(item: ToolbarWidgetPattern<string>, mods?: ModifierKeys) {
5555 } as unknown as PointerEvent ;
5656}
5757
58+ function keyEvent ( key : string , target ?: Element ) : KeyboardEvent {
59+ const event = createKeyboardEvent ( 'keydown' , 0 , key ) ;
60+ if ( target ) {
61+ Object . defineProperty ( event , 'target' , { value : target , configurable : true } ) ;
62+ Object . defineProperty ( event , 'composedPath' , { value : ( ) => [ target ] , configurable : true } ) ;
63+ }
64+ return event ;
65+ }
66+
67+ function pointerdownEvent ( target : Element ) {
68+ let defaultPrevented = false ;
69+ return {
70+ target,
71+ composedPath : ( ) => [ target ] ,
72+ defaultPrevented : ( ) => defaultPrevented ,
73+ preventDefault : ( ) => {
74+ defaultPrevented = true ;
75+ } ,
76+ } as unknown as PointerEvent & { defaultPrevented : ( ) => boolean } ;
77+ }
78+
5879function getToolbarPattern (
5980 inputs : Partial < {
6081 [ K in keyof TestInputs ] : TestInputs [ K ] extends WritableSignalLike < infer T > ? T : never ;
@@ -96,15 +117,20 @@ function getWidgetPattern(
96117 value : string ,
97118 toolbar : ToolbarPattern < string > ,
98119 group ?: ToolbarWidgetGroupPattern < ToolbarWidgetPattern < string > , string > ,
120+ options ?: {
121+ element ?: HTMLElement ;
122+ selectable ?: SignalLike < boolean > ;
123+ } ,
99124) : TestWidget {
100- const element = signal ( document . createElement ( 'button' ) ) ;
125+ const element = signal ( options ?. element ?? document . createElement ( 'button' ) ) ;
101126 const widget = new ToolbarWidgetPattern < string > ( {
102127 id : signal ( `widget-${ value } ` ) ,
103128 element,
104129 disabled : signal ( false ) ,
105130 value : signal ( value ) ,
106131 group : signal ( group ) ,
107132 toolbar : signal ( toolbar ) ,
133+ selectable : options ?. selectable ,
108134 } ) ;
109135 return widget as TestWidget ;
110136}
@@ -1296,22 +1322,28 @@ describe('Toolbar Pattern', () => {
12961322 } ) ;
12971323
12981324 describe ( 'Selection' , ( ) => {
1299- it ( 'should toggle the active item on Enter (selection)' , ( ) => {
1325+ it ( 'should toggle the active item in a group on Enter (selection)' , ( ) => {
13001326 const { toolbar} = getPatterns ( ) ;
1301- expect ( getItem ( toolbar , 'item 0' ) . selected ( ) ) . toBeFalse ( ) ;
1327+ // Navigate to item 2 (in group 0)
1328+ toolbar . onKeydown ( right ( ) ) ;
1329+ toolbar . onKeydown ( right ( ) ) ;
1330+ expect ( getItem ( toolbar , 'item 2' ) . selected ( ) ) . toBeFalse ( ) ;
13021331 toolbar . onKeydown ( enter ( ) ) ;
1303- expect ( getItem ( toolbar , 'item 0 ' ) . selected ( ) ) . toBeTrue ( ) ;
1332+ expect ( getItem ( toolbar , 'item 2 ' ) . selected ( ) ) . toBeTrue ( ) ;
13041333 toolbar . onKeydown ( enter ( ) ) ;
1305- expect ( getItem ( toolbar , 'item 0 ' ) . selected ( ) ) . toBeFalse ( ) ;
1334+ expect ( getItem ( toolbar , 'item 2 ' ) . selected ( ) ) . toBeFalse ( ) ;
13061335 } ) ;
13071336
1308- it ( 'should toggle the active item on Space (selection)' , ( ) => {
1337+ it ( 'should toggle the active item in a group on Space (selection)' , ( ) => {
13091338 const { toolbar} = getPatterns ( ) ;
1310- expect ( getItem ( toolbar , 'item 0' ) . selected ( ) ) . toBeFalse ( ) ;
1339+ // Navigate to item 2 (in group 0)
1340+ toolbar . onKeydown ( right ( ) ) ;
1341+ toolbar . onKeydown ( right ( ) ) ;
1342+ expect ( getItem ( toolbar , 'item 2' ) . selected ( ) ) . toBeFalse ( ) ;
13111343 toolbar . onKeydown ( space ( ) ) ;
1312- expect ( getItem ( toolbar , 'item 0 ' ) . selected ( ) ) . toBeTrue ( ) ;
1344+ expect ( getItem ( toolbar , 'item 2 ' ) . selected ( ) ) . toBeTrue ( ) ;
13131345 toolbar . onKeydown ( space ( ) ) ;
1314- expect ( getItem ( toolbar , 'item 0 ' ) . selected ( ) ) . toBeFalse ( ) ;
1346+ expect ( getItem ( toolbar , 'item 2 ' ) . selected ( ) ) . toBeFalse ( ) ;
13151347 } ) ;
13161348
13171349 it ( 'should toggle the active item on click (selection)' , ( ) => {
@@ -1323,19 +1355,18 @@ describe('Toolbar Pattern', () => {
13231355 expect ( getItem ( toolbar , 'item 0' ) . selected ( ) ) . toBeFalse ( ) ;
13241356 } ) ;
13251357
1326- it ( 'should be able to select multiple items in the toolbar (selection)' , ( ) => {
1327- const { toolbar} = getPatterns ( ) ;
1358+ it ( 'should be able to select multiple items via click (selection)' , ( ) => {
1359+ const { toolbar, items } = getPatterns ( ) ;
13281360 expect ( getItem ( toolbar , 'item 0' ) . selected ( ) ) . toBeFalse ( ) ;
13291361 expect ( getItem ( toolbar , 'item 1' ) . selected ( ) ) . toBeFalse ( ) ;
13301362
13311363 // Select first item
1332- toolbar . onKeydown ( enter ( ) ) ;
1364+ toolbar . onClick ( clickItem ( items [ 0 ] ) ) ;
13331365 expect ( getItem ( toolbar , 'item 0' ) . selected ( ) ) . toBeTrue ( ) ;
13341366 expect ( getItem ( toolbar , 'item 1' ) . selected ( ) ) . toBeFalse ( ) ;
13351367
1336- // Navigate to and select second item
1337- toolbar . onKeydown ( right ( ) ) ;
1338- toolbar . onKeydown ( space ( ) ) ;
1368+ // Select second item
1369+ toolbar . onClick ( clickItem ( items [ 1 ] ) ) ;
13391370 expect ( getItem ( toolbar , 'item 0' ) . selected ( ) ) . toBeTrue ( ) ;
13401371 expect ( getItem ( toolbar , 'item 1' ) . selected ( ) ) . toBeTrue ( ) ;
13411372 } ) ;
@@ -1363,12 +1394,8 @@ describe('Toolbar Pattern', () => {
13631394 const { toolbar, items} = getPatterns ( ) ;
13641395 items [ 1 ] . inputs . disabled . set ( true ) ;
13651396
1366- // Navigate to disabled item
1367- toolbar . onKeydown ( right ( ) ) ;
1368- expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'item 1' ) ;
1369-
1370- // Try to select disabled item
1371- toolbar . onKeydown ( enter ( ) ) ;
1397+ // Try to click disabled item
1398+ toolbar . onClick ( clickItem ( items [ 1 ] ) ) ;
13721399 expect ( getItem ( toolbar , 'item 1' ) . selected ( ) ) . toBeFalse ( ) ;
13731400 } ) ;
13741401
@@ -1395,31 +1422,130 @@ describe('Toolbar Pattern', () => {
13951422 expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'item 0' ) ; // Should reset to item 0
13961423 } ) ;
13971424
1398- it ( 'should NOT set default state if keyboard interacted' , ( ) => {
1425+ it ( 'should not set default state if already interacted' , ( ) => {
13991426 const { toolbar, items} = getPatterns ( ) ;
1400- toolbar . inputs . activeItem . set ( items [ 0 ] ) ;
1401- toolbar . onKeydown ( right ( ) ) ; // Interaction (ArrowRight moves to item 1)
1402-
1427+ toolbar . inputs . activeItem . set ( items [ 1 ] ) ; // Set to item 1
1428+ toolbar . onKeydown ( right ( ) ) ; // Mark interacted
14031429 toolbar . setDefaultStateEffect ( ) ;
1404- expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'item 1 ' ) ; // Should stay on item 1 (interacted)
1430+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'item 2 ' ) ; // Retains navigation target
14051431 } ) ;
1432+ } ) ;
14061433
1407- it ( 'should NOT set default state if pointer interacted' , ( ) => {
1408- const { toolbar, items} = getPatterns ( ) ;
1409- toolbar . inputs . activeItem . set ( items [ 1 ] ) ;
1410- toolbar . onPointerdown ( clickItem ( items [ 1 ] ) ) ; // Interaction
1434+ describe ( 'Form Controls & Composite Widgets Support (Generic Handling)' , ( ) => {
1435+ describe ( 'Pointerdown event handling' , ( ) => {
1436+ it ( 'should mark interacted without preventDefault' , ( ) => {
1437+ const { toolbar} = getPatterns ( ) ;
1438+ const divEl = document . createElement ( 'div' ) ;
1439+ const event = pointerdownEvent ( divEl ) ;
1440+ toolbar . onPointerdown ( event ) ;
1441+ expect ( event . defaultPrevented ( ) ) . toBeFalse ( ) ;
1442+ expect ( toolbar . hasBeenInteracted ( ) ) . toBeTrue ( ) ;
1443+ } ) ;
1444+ } ) ;
14111445
1412- toolbar . setDefaultStateEffect ( ) ;
1413- expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'item 1' ) ; // Should stay on item 1
1446+ describe ( 'Standalone widget keyboard navigation (horizontal)' , ( ) => {
1447+ let toolbar : ToolbarPattern < string > ;
1448+ let selectEl : HTMLSelectElement ;
1449+ let selectWidget : TestWidget ;
1450+ let inputEl : HTMLInputElement ;
1451+ let inputWidget : TestWidget ;
1452+ let nextWidget : TestWidget ;
1453+
1454+ beforeEach ( ( ) => {
1455+ selectEl = document . createElement ( 'select' ) ;
1456+ inputEl = document . createElement ( 'input' ) ;
1457+ inputEl . type = 'number' ;
1458+
1459+ const items = signal < TestItem [ ] > ( [ ] ) ;
1460+ const setup = getToolbarPattern ( { orientation : 'horizontal' } , items ) ;
1461+ toolbar = setup . toolbar ;
1462+
1463+ selectWidget = getWidgetPattern ( 'select-widget' , toolbar , undefined , {
1464+ element : selectEl ,
1465+ } ) ;
1466+ inputWidget = getWidgetPattern ( 'number-widget' , toolbar , undefined , {
1467+ element : inputEl ,
1468+ } ) ;
1469+ nextWidget = getWidgetPattern ( 'button-next' , toolbar ) ;
1470+
1471+ items . set ( [ selectWidget , inputWidget , nextWidget ] ) ;
1472+ toolbar . setDefaultState ( ) ;
1473+ } ) ;
1474+
1475+ it ( 'should NOT move toolbar focus on ArrowUp / ArrowDown for standalone widgets' , ( ) => {
1476+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'select-widget' ) ;
1477+
1478+ toolbar . onKeydown ( keyEvent ( 'ArrowDown' , selectEl ) ) ;
1479+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'select-widget' ) ;
1480+
1481+ toolbar . onKeydown ( keyEvent ( 'ArrowUp' , selectEl ) ) ;
1482+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'select-widget' ) ;
1483+ } ) ;
1484+
1485+ it ( 'should move toolbar focus on ArrowRight / ArrowLeft across standalone widgets' , ( ) => {
1486+ toolbar . onKeydown ( keyEvent ( 'ArrowRight' , selectEl ) ) ;
1487+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'number-widget' ) ;
1488+
1489+ toolbar . onKeydown ( keyEvent ( 'ArrowRight' , inputEl ) ) ;
1490+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'button-next' ) ;
1491+
1492+ toolbar . onKeydown ( keyEvent ( 'ArrowLeft' , nextWidget . element ( ) ) ) ;
1493+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'number-widget' ) ;
1494+ } ) ;
1495+
1496+ it ( 'should navigate to first/last on Home/End' , ( ) => {
1497+ toolbar . onKeydown ( keyEvent ( 'End' , selectEl ) ) ;
1498+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'button-next' ) ;
1499+
1500+ toolbar . onKeydown ( keyEvent ( 'Home' , nextWidget . element ( ) ) ) ;
1501+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'select-widget' ) ;
1502+ } ) ;
1503+
1504+ it ( 'should NOT select widget when selectable is false' , ( ) => {
1505+ const nonSelectableWidget = getWidgetPattern ( 'non-sel' , toolbar , undefined , {
1506+ selectable : signal ( false ) ,
1507+ } ) ;
1508+ expect ( nonSelectableWidget . selectable ( ) ) . toBeFalse ( ) ;
1509+
1510+ toolbar . onClick ( clickItem ( nonSelectableWidget ) ) ;
1511+ expect ( nonSelectableWidget . selected ( ) ) . toBeFalse ( ) ;
1512+ expect ( toolbar . listBehavior . inputs . value ( ) ) . toEqual ( [ ] ) ;
1513+ } ) ;
14141514 } ) ;
14151515
1416- it ( 'should NOT set default state if focus-in occurred' , ( ) => {
1417- const { toolbar, items} = getPatterns ( ) ;
1418- toolbar . inputs . activeItem . set ( items [ 1 ] ) ;
1419- toolbar . onFocusIn ( ) ; // Interaction
1516+ describe ( 'Non-selectable widgets inside widget group' , ( ) => {
1517+ it ( 'should NOT toggle on Space or Enter when selectable is false in group' , ( ) => {
1518+ const items = signal < TestItem [ ] > ( [ ] ) ;
1519+ const setup = getToolbarPattern ( { orientation : 'horizontal' } , items ) ;
1520+ const toolbar = setup . toolbar ;
1521+
1522+ const group = new ToolbarWidgetGroupPattern < TestWidget , string > ( {
1523+ disabled : signal ( false ) ,
1524+ multi : signal ( false ) ,
1525+ toolbar : ( ) => toolbar ,
1526+ items : signal ( [ ] ) ,
1527+ } ) ;
14201528
1421- toolbar . setDefaultStateEffect ( ) ;
1422- expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'item 1' ) ; // Should stay on item 1
1529+ const widget1 = getWidgetPattern ( 'w1' , toolbar , group , { selectable : signal ( false ) } ) ;
1530+ const widget2 = getWidgetPattern ( 'w2' , toolbar , group , { selectable : signal ( true ) } ) ;
1531+ ( group . inputs . items as any ) . set ( [ widget1 , widget2 ] ) ;
1532+ items . set ( [ widget1 , widget2 ] ) ;
1533+ toolbar . setDefaultState ( ) ;
1534+
1535+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'w1' ) ;
1536+ toolbar . onKeydown ( space ( ) ) ;
1537+ expect ( widget1 . selected ( ) ) . toBeFalse ( ) ;
1538+
1539+ toolbar . onKeydown ( enter ( ) ) ;
1540+ expect ( widget1 . selected ( ) ) . toBeFalse ( ) ;
1541+
1542+ // Navigate to selectable widget2 in group
1543+ toolbar . onKeydown ( right ( ) ) ;
1544+ expect ( toolbar . activeItem ( ) ?. value ( ) ) . toBe ( 'w2' ) ;
1545+
1546+ toolbar . onKeydown ( enter ( ) ) ;
1547+ expect ( widget2 . selected ( ) ) . toBeTrue ( ) ;
1548+ } ) ;
14231549 } ) ;
14241550 } ) ;
14251551} ) ;
0 commit comments