'test_form_1', 'priority' => 10, 'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, // admin, personal 'section_id' => 'additional', 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL, // external, internal (handled by core to store in appconfig and preferences) 'title' => 'Test declarative settings', // NcSettingsSection name 'description' => 'These fields are rendered dynamically from declarative schema', // NcSettingsSection description 'doc_url' => '', // NcSettingsSection doc_url for documentation or help page, empty string if not needed 'fields' => [ [ 'id' => 'test_field_7', // configkey 'title' => 'Multi-selection', // name or label 'description' => 'Select some option setting', // hint 'type' => DeclarativeSettingsTypes::MULTI_SELECT, 'options' => ['foo', 'bar', 'baz'], // simple options for select, radio, multi-select 'placeholder' => 'Select some multiple options', // input placeholder 'default' => ['foo', 'bar'], ], [ 'id' => 'some_real_setting', 'title' => 'Select single option', 'description' => 'Single option radio buttons', 'type' => DeclarativeSettingsTypes::RADIO, // radio (NcCheckboxRadioSwitch type radio) 'placeholder' => 'Select single option, test interval', 'default' => '40m', 'options' => [ [ 'name' => 'Each 40 minutes', // NcCheckboxRadioSwitch display name 'value' => '40m' // NcCheckboxRadioSwitch value ], [ 'name' => 'Each 60 minutes', 'value' => '60m' ], [ 'name' => 'Each 120 minutes', 'value' => '120m' ], [ 'name' => 'Each day', 'value' => 60 * 24 . 'm' ], ], ], [ 'id' => 'test_field_1', // configkey 'title' => 'Default text field', // label 'description' => 'Set some simple text setting', // hint 'type' => DeclarativeSettingsTypes::TEXT, 'placeholder' => 'Enter text setting', // placeholder 'default' => 'foo', ], [ 'id' => 'test_field_1_1', 'title' => 'Email field', 'description' => 'Set email config', 'type' => DeclarativeSettingsTypes::EMAIL, 'placeholder' => 'Enter email', 'default' => '', ], [ 'id' => 'test_field_1_2', 'title' => 'Tel field', 'description' => 'Set tel config', 'type' => DeclarativeSettingsTypes::TEL, 'placeholder' => 'Enter your tel', 'default' => '', ], [ 'id' => 'test_field_1_3', 'title' => 'Url (website) field', 'description' => 'Set url config', 'type' => 'url', 'placeholder' => 'Enter url', 'default' => '', ], [ 'id' => 'test_field_1_4', 'title' => 'Number field', 'description' => 'Set number config', 'type' => DeclarativeSettingsTypes::NUMBER, 'placeholder' => 'Enter number value', 'default' => 0, ], [ 'id' => 'test_field_2', 'title' => 'Password', 'description' => 'Set some secure value setting', 'type' => 'password', 'placeholder' => 'Set secure value', 'default' => '', ], [ 'id' => 'test_field_3', 'title' => 'Selection', 'description' => 'Select some option setting', 'type' => DeclarativeSettingsTypes::SELECT, 'options' => ['foo', 'bar', 'baz'], 'placeholder' => 'Select some option setting', 'default' => 'foo', ], [ 'id' => 'test_field_4', 'title' => 'Toggle something', 'description' => 'Select checkbox option setting', 'type' => DeclarativeSettingsTypes::CHECKBOX, 'label' => 'Verify something if enabled', 'default' => false, ], [ 'id' => 'test_field_5', 'title' => 'Multiple checkbox toggles, describing one setting, checked options are saved as an JSON object {foo: true, bar: false}', 'description' => 'Select checkbox option setting', 'type' => DeclarativeSettingsTypes::MULTI_CHECKBOX, 'default' => ['foo' => true, 'bar' => true], 'options' => [ [ 'name' => 'Foo', 'value' => 'foo', // multiple-checkbox configkey ], [ 'name' => 'Bar', 'value' => 'bar', ], [ 'name' => 'Baz', 'value' => 'baz', ], [ 'name' => 'Qux', 'value' => 'qux', ], ], ], [ 'id' => 'test_field_6', 'title' => 'Radio toggles, describing one setting like single select', 'description' => 'Select radio option setting', 'type' => DeclarativeSettingsTypes::RADIO, // radio (NcCheckboxRadioSwitch type radio) 'label' => 'Select single toggle', 'default' => 'foo', 'options' => [ [ 'name' => 'First radio', // NcCheckboxRadioSwitch display name 'value' => 'foo' // NcCheckboxRadioSwitch value ], [ 'name' => 'Second radio', 'value' => 'bar' ], [ 'name' => 'Second radio', 'value' => 'baz' ], ], ], [ 'id' => 'test_sensitive_field', 'title' => 'Sensitive text field', 'description' => 'Set some secure value setting that is stored encrypted', 'type' => DeclarativeSettingsTypes::TEXT, 'label' => 'Sensitive field', 'placeholder' => 'Set secure value', 'default' => '', 'sensitive' => true, // only for TEXT, PASSWORD types ], [ 'id' => 'test_sensitive_field_2', 'title' => 'Sensitive password field', 'description' => 'Set some password setting that is stored encrypted', 'type' => DeclarativeSettingsTypes::PASSWORD, 'label' => 'Sensitive field', 'placeholder' => 'Set secure value', 'default' => '', 'sensitive' => true, // only for TEXT, PASSWORD types ], [ 'id' => 'test_non_sensitive_field', 'title' => 'Password field', 'description' => 'Set some password setting', 'type' => DeclarativeSettingsTypes::PASSWORD, 'label' => 'Password field', 'placeholder' => 'Set secure value', 'default' => '', 'sensitive' => false, ], ], ]; public static bool $testSetInternalValueAfterChange = false; protected function setUp(): void { parent::setUp(); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->groupManager = $this->createMock(IGroupManager::class); $this->coordinator = $this->createMock(Coordinator::class); $this->config = $this->createMock(IConfig::class); $this->appConfig = $this->createMock(IAppConfig::class); $this->logger = $this->createMock(LoggerInterface::class); $this->crypto = $this->createMock(ICrypto::class); $this->declarativeManager = new DeclarativeManager( $this->eventDispatcher, $this->groupManager, $this->coordinator, $this->config, $this->appConfig, $this->logger, $this->crypto, ); $this->user = $this->createMock(IUser::class); $this->user->expects($this->any()) ->method('getUID') ->willReturn('test_user'); $this->adminUser = $this->createMock(IUser::class); $this->adminUser->expects($this->any()) ->method('getUID') ->willReturn('admin_test_user'); $this->groupManager->expects($this->any()) ->method('isAdmin') ->willReturnCallback(function ($userId) { return $userId === 'admin_test_user'; }); } public function testRegisterSchema(): void { $app = 'testing'; $schema = self::validSchemaAllFields; $this->declarativeManager->registerSchema($app, $schema); $formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']); $this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app])); } /** * Simple test to verify that exception is thrown when trying to register schema with duplicate id */ public function testRegisterDuplicateSchema(): void { $this->declarativeManager->registerSchema('testing', self::validSchemaAllFields); $this->expectException(\Exception::class); $this->declarativeManager->registerSchema('testing', self::validSchemaAllFields); } /** * It's not allowed to register schema with duplicate fields ids for the same app */ public function testRegisterSchemaWithDuplicateFields(): void { // Register first valid schema $this->declarativeManager->registerSchema('testing', self::validSchemaAllFields); // Register second schema with duplicate fields, but different schema id $this->expectException(\Exception::class); $schema = self::validSchemaAllFields; $schema['id'] = 'test_form_2'; $this->declarativeManager->registerSchema('testing', $schema); } public function testRegisterMultipleSchemasAndDuplicate(): void { $app = 'testing'; $schema = self::validSchemaAllFields; $this->declarativeManager->registerSchema($app, $schema); $formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']); // 1. Check that form is registered for the app $this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app])); $app = 'testing2'; $this->declarativeManager->registerSchema($app, $schema); $formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']); // 2. Check that form is registered for the second app $this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app])); $app = 'testing'; $this->expectException(\Exception::class); // expecting duplicate form id and duplicate fields ids exception $this->declarativeManager->registerSchema($app, $schema); $schemaDuplicateFields = self::validSchemaAllFields; $schemaDuplicateFields['id'] = 'test_form_2'; // change form id to test duplicate fields $this->declarativeManager->registerSchema($app, $schemaDuplicateFields); // 3. Check that not valid form with duplicate fields is not registered $formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schemaDuplicateFields['section_type'], $schemaDuplicateFields['section_id']); $this->assertFalse(isset($formIds[$app]) && in_array($schemaDuplicateFields['id'], $formIds[$app])); } #[\PHPUnit\Framework\Attributes\DataProvider('dataValidateSchema')] public function testValidateSchema(bool $expected, bool $expectException, string $app, array $schema): void { if ($expectException) { $this->expectException(\Exception::class); } $this->declarativeManager->registerSchema($app, $schema); $formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']); $this->assertEquals($expected, isset($formIds[$app]) && in_array($schema['id'], $formIds[$app])); } public static function dataValidateSchema(): array { return [ 'valid schema with all supported fields' => [ true, false, 'testing', self::validSchemaAllFields, ], 'invalid schema with missing id' => [ false, true, 'testing', [ 'priority' => 10, 'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, 'section_id' => 'additional', 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL, 'title' => 'Test declarative settings', 'description' => 'These fields are rendered dynamically from declarative schema', 'doc_url' => '', 'fields' => [ [ 'id' => 'test_field_7', 'title' => 'Multi-selection', 'description' => 'Select some option setting', 'type' => DeclarativeSettingsTypes::MULTI_SELECT, 'options' => ['foo', 'bar', 'baz'], 'placeholder' => 'Select some multiple options', 'default' => ['foo', 'bar'], ], ], ], ], 'invalid schema with invalid field' => [ false, true, 'testing', [ 'id' => 'test_form_1', 'priority' => 10, 'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, 'section_id' => 'additional', 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL, 'title' => 'Test declarative settings', 'description' => 'These fields are rendered dynamically from declarative schema', 'doc_url' => '', 'fields' => [ [ 'id' => 'test_invalid_field', 'title' => 'Invalid field', 'description' => 'Some invalid setting description', 'type' => 'some_invalid_type', 'placeholder' => 'Some invalid field placeholder', 'default' => null, ], ], ], ], ]; } public function testGetFormIDs(): void { $app = 'testing'; $schema = self::validSchemaAllFields; $this->declarativeManager->registerSchema($app, $schema); $formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']); $this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app])); $app = 'testing2'; $this->declarativeManager->registerSchema($app, $schema); $formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']); $this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app])); } /** * Check that form with default values is returned with internal storage_type */ public function testGetFormsWithDefaultValues(): void { $app = 'testing'; $schema = self::validSchemaAllFields; $this->declarativeManager->registerSchema($app, $schema); $this->config->expects($this->any()) ->method('getAppValue') ->willReturnCallback(fn ($app, $configkey, $default) => $default); $forms = $this->declarativeManager->getFormsWithValues($this->adminUser, $schema['section_type'], $schema['section_id']); $this->assertNotEmpty($forms); $this->assertTrue(array_search($schema['id'], array_column($forms, 'id')) !== false); // Check some_real_setting field default value $someRealSettingField = array_values(array_filter(array_filter($forms, fn ($form) => $form['id'] === $schema['id'])[0]['fields'], fn ($field) => $field['id'] === 'some_real_setting'))[0]; $schemaSomeRealSettingField = array_values(array_filter($schema['fields'], fn ($field) => $field['id'] === 'some_real_setting'))[0]; $this->assertEquals($schemaSomeRealSettingField['default'], $someRealSettingField['default']); } /** * Check values in json format to ensure that they are properly encoded */ public function testGetFormsWithDefaultValuesJson(): void { $app = 'testing'; $schema = [ 'id' => 'test_form_1', 'priority' => 10, 'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL, 'section_id' => 'additional', 'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL, 'title' => 'Test declarative settings', 'description' => 'These fields are rendered dynamically from declarative schema', 'doc_url' => '', 'fields' => [ [ 'id' => 'test_field_json', 'title' => 'Multi-selection', 'description' => 'Select some option setting', 'type' => DeclarativeSettingsTypes::MULTI_SELECT, 'options' => ['foo', 'bar', 'baz'], 'placeholder' => 'Select some multiple options', 'default' => ['foo', 'bar'], ], ], ]; $this->declarativeManager->registerSchema($app, $schema); // config->getUserValue() should be called with json encoded default value $this->config->expects($this->once()) ->method('getUserValue') ->with($this->adminUser->getUID(), $app, 'test_field_json', json_encode($schema['fields'][0]['default'])) ->willReturn(json_encode($schema['fields'][0]['default'])); $forms = $this->declarativeManager->getFormsWithValues($this->adminUser, $schema['section_type'], $schema['section_id']); $this->assertNotEmpty($forms); $this->assertTrue(array_search($schema['id'], array_column($forms, 'id')) !== false); $testFieldJson = array_values(array_filter(array_filter($forms, fn ($form) => $form['id'] === $schema['id'])[0]['fields'], fn ($field) => $field['id'] === 'test_field_json'))[0]; $this->assertEquals(json_encode($schema['fields'][0]['default']), $testFieldJson['value']); } /** * Check that saving value for field with internal storage_type is handled by core */ public function testSetInternalValue(): void { $app = 'testing'; $schema = self::validSchemaAllFields; $this->declarativeManager->registerSchema($app, $schema); self::$testSetInternalValueAfterChange = false; $this->config->expects($this->any()) ->method('getAppValue') ->willReturnCallback(function ($app, $configkey, $default) { if ($configkey === 'some_real_setting' && self::$testSetInternalValueAfterChange) { return '120m'; } return $default; }); $this->appConfig->expects($this->once()) ->method('setValueString') ->with($app, 'some_real_setting', '120m'); $forms = $this->declarativeManager->getFormsWithValues($this->adminUser, $schema['section_type'], $schema['section_id']); $someRealSettingField = array_values(array_filter(array_filter($forms, fn ($form) => $form['id'] === $schema['id'])[0]['fields'], fn ($field) => $field['id'] === 'some_real_setting'))[0]; $this->assertEquals('40m', $someRealSettingField['value']); // first check that default value (40m) is returned // Set new value for some_real_setting field $this->declarativeManager->setValue($this->adminUser, $app, $schema['id'], 'some_real_setting', '120m'); self::$testSetInternalValueAfterChange = true; $forms = $this->declarativeManager->getFormsWithValues($this->adminUser, $schema['section_type'], $schema['section_id']); $this->assertNotEmpty($forms); $this->assertTrue(array_search($schema['id'], array_column($forms, 'id')) !== false); // Check some_real_setting field default value $someRealSettingField = array_values(array_filter(array_filter($forms, fn ($form) => $form['id'] === $schema['id'])[0]['fields'], fn ($field) => $field['id'] === 'some_real_setting'))[0]; $this->assertEquals('120m', $someRealSettingField['value']); } public function testSetExternalValue(): void { $app = 'testing'; $schema = self::validSchemaAllFields; // Change storage_type to external and section_type to personal $schema['storage_type'] = DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL; $schema['section_type'] = DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL; $this->declarativeManager->registerSchema($app, $schema); $setDeclarativeSettingsValueEvent = new DeclarativeSettingsSetValueEvent( $this->adminUser, $app, $schema['id'], 'some_real_setting', '120m' ); $this->eventDispatcher->expects($this->once()) ->method('dispatchTyped') ->with($setDeclarativeSettingsValueEvent); $this->declarativeManager->setValue($this->adminUser, $app, $schema['id'], 'some_real_setting', '120m'); } public function testAdminFormUserUnauthorized(): void { $app = 'testing'; $schema = self::validSchemaAllFields; $this->declarativeManager->registerSchema($app, $schema); $this->expectException(\Exception::class); $this->declarativeManager->getFormsWithValues($this->user, $schema['section_type'], $schema['section_id']); } /** * Ensure that the `setValue` method is called if the form implements the handler interface. */ public function testSetValueWithHandler(): void { $schema = self::validSchemaAllFields; $schema['storage_type'] = DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL; $form = $this->createMock(IDeclarativeSettingsFormWithHandlers::class); $form->expects(self::atLeastOnce()) ->method('getSchema') ->willReturn($schema); // The setter should be called once! $form->expects(self::once()) ->method('setValue') ->with('test_field_2', 'some password', $this->adminUser); \OC::$server->registerService('OCA\\Testing\\Settings\\DeclarativeForm', fn () => $form, false); $context = $this->createMock(RegistrationContext::class); $context->expects(self::atLeastOnce()) ->method('getDeclarativeSettings') ->willReturn([new ServiceRegistration('testing', 'OCA\\Testing\\Settings\\DeclarativeForm')]); $this->coordinator->expects(self::atLeastOnce()) ->method('getRegistrationContext') ->willReturn($context); $this->declarativeManager->loadSchemas(); $this->eventDispatcher->expects(self::never()) ->method('dispatchTyped'); $this->declarativeManager->setValue($this->adminUser, 'testing', 'test_form_1', 'test_field_2', 'some password'); } public function testGetValueWithHandler(): void { $schema = self::validSchemaAllFields; $schema['storage_type'] = DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL; $form = $this->createMock(IDeclarativeSettingsFormWithHandlers::class); $form->expects(self::atLeastOnce()) ->method('getSchema') ->willReturn($schema); // The setter should be called once! $form->expects(self::once()) ->method('getValue') ->with('test_field_2', $this->adminUser) ->willReturn('very secret password'); \OC::$server->registerService('OCA\\Testing\\Settings\\DeclarativeForm', fn () => $form, false); $context = $this->createMock(RegistrationContext::class); $context->expects(self::atLeastOnce()) ->method('getDeclarativeSettings') ->willReturn([new ServiceRegistration('testing', 'OCA\\Testing\\Settings\\DeclarativeForm')]); $this->coordinator->expects(self::atLeastOnce()) ->method('getRegistrationContext') ->willReturn($context); $this->declarativeManager->loadSchemas(); $this->eventDispatcher->expects(self::never()) ->method('dispatchTyped'); $password = $this->invokePrivate($this->declarativeManager, 'getValue', [$this->adminUser, 'testing', 'test_form_1', 'test_field_2']); self::assertEquals('very secret password', $password); } } 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618
/*!
 * jQuery UI Menu @VERSION
 * http://jqueryui.com
 *
 * Copyright 2012 jQuery Foundation and other contributors
 * Dual licensed under the MIT or GPL Version 2 licenses.
 * http://jquery.org/license
 *
 * http://docs.jquery.com/UI/Menu
 *
 * Depends:
 *	jquery.ui.core.js
 *	jquery.ui.widget.js
 *	jquery.ui.position.js
 */
(function( $, undefined ) {

var currentEventTarget = null;

$.widget( "ui.menu", {
	version: "@VERSION",
	defaultElement: "<ul>",
	delay: 300,
	options: {
		menus: "ul",
		position: {
			my: "left top",
			at: "right top"
		},
		role: "menu",

		// callbacks
		blur: null,
		focus: null,
		select: null
	},

	_create: function() {
		this.activeMenu = this.element;
		this.element
			.uniqueId()
			.addClass( "ui-menu ui-widget ui-widget-content ui-corner-all" )
			.toggleClass( "ui-menu-icons", !!this.element.find( ".ui-icon" ).length )
			.attr({
				role: this.options.role,
				tabIndex: 0
			})
			// need to catch all clicks on disabled menu
			// not possible through _on
			.bind( "click" + this.eventNamespace, $.proxy(function( event ) {
				if ( this.options.disabled ) {
					event.preventDefault();
				}
			}, this ));

		if ( this.options.disabled ) {
			this.element
				.addClass( "ui-state-disabled" )
				.attr( "aria-disabled", "true" );
		}

		this._on({
			// Prevent focus from sticking to links inside menu after clicking
			// them (focus should always stay on UL during navigation).
			"mousedown .ui-menu-item > a": function( event ) {
				event.preventDefault();
			},
			"click .ui-state-disabled > a": function( event ) {
				event.preventDefault();
			},
			"click .ui-menu-item:has(a)": function( event ) {
				var target = $( event.target );
				if ( target[0] !== currentEventTarget ) {
					currentEventTarget = target[0];
					// TODO: What are we trying to accomplish with this check?
					// Clicking a menu item twice results in a select event with
					// an empty ui.item.
					target.one( "click" + this.eventNamespace, function( event ) {
						currentEventTarget = null;
					});
					// Don't select disabled menu items
					if ( !target.closest( ".ui-menu-item" ).is( ".ui-state-disabled" ) ) {
						this.select( event );
						// Redirect focus to the menu with a delay for firefox
						this._delay(function() {
							if ( !this.element.is(":focus") ) {
								this.element.focus();
							}
						}, 20 );
					}
				}
			},
			"mouseenter .ui-menu-item": function( event ) {
				var target = $( event.currentTarget );
				// Remove ui-state-active class from siblings of the newly focused menu item
				// to avoid a jump caused by adjacent elements both having a class with a border
				target.siblings().children( ".ui-state-active" ).removeClass( "ui-state-active" );
				this.focus( event, target );
			},
			mouseleave: "collapseAll",
			"mouseleave .ui-menu": "collapseAll",
			focus: function( event ) {
				var menuTop,
					menu = this.element,
					// Default to focusing the first item
					item = menu.children( ".ui-menu-item" ).eq( 0 );

				// If there's already an active item, keep it active
				if ( this.active ) {
					item = this.active;
				// If there's no active item and the menu is scrolled,
				// then find the first visible item
				} else if ( this._hasScroll() ) {
					menuTop = menu.offset().top;
					menu.children().each(function() {
						var currentItem = $( this );
						if ( currentItem.offset().top - menuTop >= 0 ) {
							item = currentItem;
							return false;
						}
					});
				}
				this.focus( event, item );
			},
			blur: function( event ) {
				this._delay(function() {
					if ( !$.contains( this.element[0], this.document[0].activeElement ) ) {
						this.collapseAll( event );
					}
				});
			},
			keydown: "_keydown"
		});

		this.refresh();

		// Clicks outside of a menu collapse any open menus
		this._on( this.document, {
			click: function( event ) {
				if ( !$( event.target ).closest( ".ui-menu" ).length ) {
					this.collapseAll( event );
				}
			}
		});
	},

	_destroy: function() {
		// Destroy (sub)menus
		this.element
			.removeAttr( "aria-activedescendant" )
			.find( ".ui-menu" ).andSelf()
				.removeClass( "ui-menu ui-widget ui-widget-content ui-corner-all ui-menu-icons" )
				.removeAttr( "role" )
				.removeAttr( "tabIndex" )
				.removeAttr( "aria-labelledby" )
				.removeAttr( "aria-expanded" )
				.removeAttr( "aria-hidden" )
				.removeAttr( "aria-disabled" )
				.removeUniqueId()
				.show();

		// Destroy menu items
		this.element.find( ".ui-menu-item" )
			.removeClass( "ui-menu-item" )
			.removeAttr( "role" )
			.removeAttr( "aria-disabled" )
			.children( "a" )
				.removeUniqueId()
				.removeClass( "ui-corner-all ui-state-hover" )
				.removeAttr( "tabIndex" )
				.removeAttr( "role" )
				.removeAttr( "aria-haspopup" )
				.children().each( function() {
					var elem = $( this );
					if ( elem.data( "ui-menu-submenu-carat" ) ) {
						elem.remove();
					}
				});

		// Destroy menu dividers
		this.element.find( ".ui-menu-divider" ).removeClass( "ui-menu-divider ui-widget-content" );

		// Unbind currentEventTarget click event handler
		this._off( $( currentEventTarget ), "click" );
	},

	_keydown: function( event ) {
		var match, prev, character, skip,
			preventDefault = true;

		function escape( value ) {
			return value.replace( /[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&" );
		}

		switch ( event.keyCode ) {
		case $.ui.keyCode.PAGE_UP:
			this.previousPage( event );
			break;
		case $.ui.keyCode.PAGE_DOWN:
			this.nextPage( event );
			break;
		case $.ui.keyCode.HOME:
			this._move( "first", "first", event );
			break;
		case $.ui.keyCode.END:
			this._move( "last", "last", event );
			break;
		case $.ui.keyCode.UP:
			this.previous( event );
			break;
		case $.ui.keyCode.DOWN:
			this.next( event );
			break;
		case $.ui.keyCode.LEFT:
			this.collapse( event );
			break;
		case $.ui.keyCode.RIGHT:
			if ( !this.active.is( ".ui-state-disabled" ) ) {
				this.expand( event );
			}
			break;
		case $.ui.keyCode.ENTER:
		case $.ui.keyCode.SPACE:
			this._activate( event );
			break;
		case $.ui.keyCode.ESCAPE:
			this.collapse( event );
			break;
		default:
			preventDefault = false;
			prev = this.previousFilter || "";
			character = String.fromCharCode( event.keyCode );
			skip = false;

			clearTimeout( this.filterTimer );

			if ( character === prev ) {
				skip = true;
			} else {
				character = prev + character;
			}

			match = this.activeMenu.children( ".ui-menu-item" ).filter(function() {
				return new RegExp( "^" + escape( character ), "i" )
					.test( $( this ).children( "a" ).text() );
			});
			match = skip && match.index( this.active.next() ) !== -1 ?
				this.active.nextAll( ".ui-menu-item" ) :
				match;

			// If no matches on the current filter, reset to the last character pressed
			// to move down the menu to the first item that starts with that character
			if ( !match.length ) {
				character = String.fromCharCode( event.keyCode );
				match = this.activeMenu.children( ".ui-menu-item" ).filter(function() {
					return new RegExp( "^" + escape( character ), "i" )
						.test( $( this ).children( "a" ).text() );
				});
			}

			if ( match.length ) {
				this.focus( event, match );
				if ( match.length > 1 ) {
					this.previousFilter = character;
					this.filterTimer = this._delay(function() {
						delete this.previousFilter;
					}, 1000 );
				} else {
					delete this.previousFilter;
				}
			} else {
				delete this.previousFilter;
			}
		}

		if ( preventDefault ) {
			event.preventDefault();
		}
	},

	_activate: function( event ) {
		if ( !this.active.is( ".ui-state-disabled" ) ) {
			if ( this.active.children( "a[aria-haspopup='true']" ).length ) {
				this.expand( event );
			} else {
				this.select( event );
			}
		}
	},

	refresh: function() {
		// Initialize nested menus
		var menus,
			submenus = this.element.find( this.options.menus + ":not(.ui-menu)" )
				.addClass( "ui-menu ui-widget ui-widget-content ui-corner-all" )
				.hide()
				.attr({
					role: this.options.role,
					"aria-hidden": "true",
					"aria-expanded": "false"
				});

		// Don't refresh list items that are already adapted
		menus = submenus.add( this.element );

		menus.children( ":not( .ui-menu-item ):has( a )" )
			.addClass( "ui-menu-item" )
			.attr( "role", "presentation" )
			.children( "a" )
				.uniqueId()
				.addClass( "ui-corner-all" )
				.attr({
					tabIndex: -1,
					role: this._itemRole()
				});

		// Initialize unlinked menu-items containing spaces and/or dashes only as dividers
		menus.children( ":not(.ui-menu-item)" ).each(function() {
			var item = $( this );
			// hyphen, em dash, en dash
			if ( !/[^\-—–\s]/.test( item.text() ) ) {
				item.addClass( "ui-widget-content ui-menu-divider" );
			}
		});

		// Add aria-disabled attribute to any disabled menu item
		menus.children( ".ui-state-disabled" ).attr( "aria-disabled", "true" );

		submenus.each(function() {
			var menu = $( this ),
				item = menu.prev( "a" ),
				submenuCarat = $( "<span>" )
					.addClass( "ui-menu-icon ui-icon ui-icon-carat-1-e" )
					.data( "ui-menu-submenu-carat", true );

			item
				.attr( "aria-haspopup", "true" )
				.prepend( submenuCarat );
			menu.attr( "aria-labelledby", item.attr( "id" ) );
		});
	},

	_itemRole: function() {
		return {
			menu: "menuitem",
			listbox: "option"
		}[ this.options.role ];
	},

	focus: function( event, item ) {
		var nested, focused;
		this.blur( event, event && event.type === "focus" );

		this._scrollIntoView( item );

		this.active = item.first();
		focused = this.active.children( "a" ).addClass( "ui-state-focus" );
		// Only update aria-activedescendant if there's a role
		// otherwise we assume focus is managed elsewhere
		if ( this.options.role ) {
			this.element.attr( "aria-activedescendant", focused.attr( "id" ) );
		}

		// Highlight active parent menu item, if any
		this.active
			.parent()
			.closest( ".ui-menu-item" )
			.children( "a:first" )
			.addClass( "ui-state-active" );

		if ( event && event.type === "keydown" ) {
			this._close();
		} else {
			this.timer = this._delay(function() {
				this._close();
			}, this.delay );
		}

		nested = item.children( ".ui-menu" );
		if ( nested.length && ( /^mouse/.test( event.type ) ) ) {
			this._startOpening(nested);
		}
		this.activeMenu = item.parent();

		this._trigger( "focus", event, { item: item } );
	},

	_scrollIntoView: function( item ) {
		var borderTop, paddingTop, offset, scroll, elementHeight, itemHeight;
		if ( this._hasScroll() ) {
			borderTop = parseFloat( $.css( this.activeMenu[0], "borderTopWidth" ) ) || 0;
			paddingTop = parseFloat( $.css( this.activeMenu[0], "paddingTop" ) ) || 0;
			offset = item.offset().top - this.activeMenu.offset().top - borderTop - paddingTop;
			scroll = this.activeMenu.scrollTop();
			elementHeight = this.activeMenu.height();
			itemHeight = item.height();

			if ( offset < 0 ) {
				this.activeMenu.scrollTop( scroll + offset );
			} else if ( offset + itemHeight > elementHeight ) {
				this.activeMenu.scrollTop( scroll + offset - elementHeight + itemHeight );
			}
		}
	},

	blur: function( event, fromFocus ) {
		if ( !fromFocus ) {
			clearTimeout( this.timer );
		}

		if ( !this.active ) {
			return;
		}

		this.active.children( "a" ).removeClass( "ui-state-focus" );
		this.active = null;

		this._trigger( "blur", event, { item: this.active } );
	},

	_startOpening: function( submenu ) {
		clearTimeout( this.timer );

		// Don't open if already open fixes a Firefox bug that caused a .5 pixel
		// shift in the submenu position when mousing over the carat icon
		if ( submenu.attr( "aria-hidden" ) !== "true" ) {
			return;
		}

		this.timer = this._delay(function() {
			this._close();
			this._open( submenu );
		}, this.delay );
	},

	_open: function( submenu ) {
		var position = $.extend({
			of: this.active
		}, $.type( this.options.position ) === "function" ?
			this.options.position( this.active ) :
			this.options.position
		);

		clearTimeout( this.timer );
		this.element.find( ".ui-menu" ).not( submenu.parents( ".ui-menu" ) )
			.hide()
			.attr( "aria-hidden", "true" );

		submenu
			.show()
			.removeAttr( "aria-hidden" )
			.attr( "aria-expanded", "true" )
			.position( position );
	},

	collapseAll: function( event, all ) {
		clearTimeout( this.timer );
		this.timer = this._delay(function() {
			// If we were passed an event, look for the submenu that contains the event
			var currentMenu = all ? this.element :
				$( event && event.target ).closest( this.element.find( ".ui-menu" ) );

			// If we found no valid submenu ancestor, use the main menu to close all sub menus anyway
			if ( !currentMenu.length ) {
				currentMenu = this.element;
			}

			this._close( currentMenu );

			this.blur( event );
			this.activeMenu = currentMenu;
		}, this.delay );
	},

	// With no arguments, closes the currently active menu - if nothing is active
	// it closes all menus.  If passed an argument, it will search for menus BELOW
	_close: function( startMenu ) {
		if ( !startMenu ) {
			startMenu = this.active ? this.active.parent() : this.element;
		}

		startMenu
			.find( ".ui-menu" )
				.hide()
				.attr( "aria-hidden", "true" )
				.attr( "aria-expanded", "false" )
			.end()
			.find( "a.ui-state-active" )
				.removeClass( "ui-state-active" );
	},

	collapse: function( event ) {
		var newItem = this.active &&
			this.active.parent().closest( ".ui-menu-item", this.element );
		if ( newItem && newItem.length ) {
			this._close();
			this.focus( event, newItem );
			return true;
		}
	},

	expand: function( event ) {
		var newItem = this.active &&
			this.active
				.children( ".ui-menu " )
				.children( ".ui-menu-item" )
				.first();

		if ( newItem && newItem.length ) {
			this._open( newItem.parent() );

			// Delay so Firefox will not hide activedescendant change in expanding submenu from AT
			this._delay(function() {
				this.focus( event, newItem );
			}, 20 );
			return true;
		}
	},

	next: function( event ) {
		this._move( "next", "first", event );
	},

	previous: function( event ) {
		this._move( "prev", "last", event );
	},

	isFirstItem: function() {
		return this.active && !this.active.prevAll( ".ui-menu-item" ).length;
	},

	isLastItem: function() {
		return this.active && !this.active.nextAll( ".ui-menu-item" ).length;
	},

	_move: function( direction, filter, event ) {
		var next;
		if ( this.active ) {
			if ( direction === "first" || direction === "last" ) {
				next = this.active
					[ direction === "first" ? "prevAll" : "nextAll" ]( ".ui-menu-item" )
					.eq( -1 );
			} else {
				next = this.active
					[ direction + "All" ]( ".ui-menu-item" )
					.eq( 0 );
			}
		}
		if ( !next || !next.length || !this.active ) {
			next = this.activeMenu.children( ".ui-menu-item" )[ filter ]();
		}

		this.focus( event, next );
	},

	nextPage: function( event ) {
		var item, base, height;

		if ( !this.active ) {
			this.next( event );
			return;
		}
		if ( this.isLastItem() ) {
			return;
		}
		if ( this._hasScroll() ) {
			base = this.active.offset().top;
			height = this.element.height();
			this.active.nextAll( ".ui-menu-item" ).each(function() {
				item = $( this );
				return item.offset().top - base - height < 0;
			});

			this.focus( event, item );
		} else {
			this.focus( event, this.activeMenu.children( ".ui-menu-item" )
				[ !this.active ? "first" : "last" ]() );
		}
	},

	previousPage: function( event ) {
		var item, base, height;
		if ( !this.active ) {
			this.next( event );
			return;
		}
		if ( this.isFirstItem() ) {
			return;
		}
		if ( this._hasScroll() ) {
			base = this.active.offset().top;
			height = this.element.height();
			this.active.prevAll( ".ui-menu-item" ).each(function() {
				item = $( this );
				return item.offset().top - base + height > 0;
			});

			this.focus( event, item );
		} else {
			this.focus( event, this.activeMenu.children( ".ui-menu-item" ).first() );
		}
	},

	_hasScroll: function() {
		return this.element.outerHeight() < this.element.prop( "scrollHeight" );
	},

	select: function( event ) {
		// Save active reference before collapseAll triggers blur
		var ui = {
			item: this.active
		};
		this.collapseAll( event, true );
		this._trigger( "select", event, ui );
	}
});

}( jQuery ));