diff --git a/packages/calcite-components/src/components/accordion-item/accordion-item.tsx b/packages/calcite-components/src/components/accordion-item/accordion-item.tsx index 56e8aa1f65d..319e681b250 100644 --- a/packages/calcite-components/src/components/accordion-item/accordion-item.tsx +++ b/packages/calcite-components/src/components/accordion-item/accordion-item.tsx @@ -8,9 +8,9 @@ import { import { CSS_UTILITY } from "../../utils/resources"; import { getIconScale } from "../../utils/component"; import { FlipContext, Position, Scale, SelectionMode, IconType, Appearance } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import { IconNameOrString } from "../icon/interfaces"; import type { Accordion } from "../accordion/accordion"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { Heading, HeadingLevel } from "../functional/Heading"; import { SLOTS, CSS, IDS, ICONS } from "./resources"; import { RequestedItem } from "./interfaces"; @@ -38,6 +38,8 @@ export class AccordionItem extends LitElement { private headerEl: HTMLDivElement; + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -113,8 +115,9 @@ export class AccordionItem extends LitElement { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.headerEl.focus(); + return this.focusSetter(() => { + return this.headerEl; + }); } // #endregion diff --git a/packages/calcite-components/src/components/action-bar/action-bar.tsx b/packages/calcite-components/src/components/action-bar/action-bar.tsx index c487fc1ccb2..671880310c3 100755 --- a/packages/calcite-components/src/components/action-bar/action-bar.tsx +++ b/packages/calcite-components/src/components/action-bar/action-bar.tsx @@ -2,12 +2,7 @@ import { debounce } from "lodash-es"; import { PropertyValues } from "lit"; import { LitElement, property, createEvent, h, method, state, JsxNode } from "@arcgis/lumina"; -import { - focusFirstTabbable, - slotChangeGetAssignedElements, - slotChangeHasAssignedElement, -} from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; +import { slotChangeGetAssignedElements, slotChangeHasAssignedElement } from "../../utils/dom"; import { createObserver } from "../../utils/observers"; import { ExpandToggle, toggleChildActionText } from "../functional/ExpandToggle"; import { Layout, Position, Scale } from "../interfaces"; @@ -17,6 +12,7 @@ import { useT9n } from "../../controllers/useT9n"; import { useCancelable } from "../../controllers/useCancelable"; import type { Tooltip } from "../tooltip/tooltip"; import type { ActionGroup } from "../action-group/action-group"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { Action } from "../action/action"; import { getOverflowCount } from "../../utils/overflow"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -107,6 +103,8 @@ export class ActionBar extends LitElement { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -177,9 +175,9 @@ export class ActionBar extends LitElement { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/action-group/action-group.tsx b/packages/calcite-components/src/components/action-group/action-group.tsx index bb445879d93..29c5c798abb 100755 --- a/packages/calcite-components/src/components/action-group/action-group.tsx +++ b/packages/calcite-components/src/components/action-group/action-group.tsx @@ -1,13 +1,13 @@ // @ts-strict-ignore import { PropertyValues } from "lit"; import { LitElement, property, h, method, state, JsxNode, ToEvents } from "@arcgis/lumina"; -import { componentFocusable } from "../../utils/component"; import { SLOTS as ACTION_MENU_SLOTS } from "../action-menu/resources"; import { Layout, Scale } from "../interfaces"; import { FlipPlacement, LogicalPlacement, OverlayPositioning } from "../../utils/floating-ui"; -import { focusFirstTabbable, slotChangeHasAssignedElement } from "../../utils/dom"; +import { slotChangeHasAssignedElement } from "../../utils/dom"; import { useT9n } from "../../controllers/useT9n"; import type { ActionMenu } from "../action-menu/action-menu"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { Columns } from "./interfaces"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, ICONS, SLOTS } from "./resources"; @@ -42,6 +42,8 @@ export class ActionGroup extends LitElement { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -99,8 +101,9 @@ export class ActionGroup extends LitElement { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/action-menu/action-menu.tsx b/packages/calcite-components/src/components/action-menu/action-menu.tsx index ada7dc2fbfa..874bf53d4b9 100755 --- a/packages/calcite-components/src/components/action-menu/action-menu.tsx +++ b/packages/calcite-components/src/components/action-menu/action-menu.tsx @@ -11,16 +11,16 @@ import { JsxNode, } from "@arcgis/lumina"; import { getRoundRobinIndex } from "../../utils/array"; -import { focusElement, toAriaBoolean } from "../../utils/dom"; +import { toAriaBoolean } from "../../utils/dom"; import { FlipPlacement, LogicalPlacement, OverlayPositioning } from "../../utils/floating-ui"; import { guid } from "../../utils/guid"; import { isActivationKey } from "../../utils/key"; -import { componentFocusable } from "../../utils/component"; import { Appearance, Scale } from "../interfaces"; import type { Action } from "../action/action"; import type { Tooltip } from "../tooltip/tooltip"; import { Popover } from "../popover/popover"; -import { CSS, ICONS, SLOTS, IDS } from "./resources"; +import { useSetFocus } from "../../controllers/useSetFocus"; +import { CSS, ICONS, IDS, SLOTS } from "./resources"; import { styles } from "./action-menu.scss"; declare global { @@ -119,6 +119,8 @@ export class ActionMenu extends LitElement { action.activeDescendant = index === activeMenuItemIndex; }; + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -152,7 +154,6 @@ export class ActionMenu extends LitElement { get open(): boolean { return this._open; } - set open(open: boolean) { const oldOpen = this._open; if (open !== oldOpen) { @@ -182,9 +183,9 @@ export class ActionMenu extends LitElement { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - return focusElement(this.menuButtonEl); + return this.focusSetter(() => { + return this.menuButtonEl; + }); } // #endregion diff --git a/packages/calcite-components/src/components/action-pad/action-pad.tsx b/packages/calcite-components/src/components/action-pad/action-pad.tsx index 9ab690951c0..2b566942787 100755 --- a/packages/calcite-components/src/components/action-pad/action-pad.tsx +++ b/packages/calcite-components/src/components/action-pad/action-pad.tsx @@ -1,8 +1,7 @@ // @ts-strict-ignore import { PropertyValues } from "lit"; import { LitElement, property, createEvent, h, method, state, JsxNode } from "@arcgis/lumina"; -import { focusFirstTabbable, slotChangeGetAssignedElements } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; +import { slotChangeGetAssignedElements } from "../../utils/dom"; import { ExpandToggle, toggleChildActionText } from "../functional/ExpandToggle"; import { Layout, Position, Scale } from "../interfaces"; import { createObserver } from "../../utils/observers"; @@ -10,6 +9,7 @@ import { OverlayPositioning } from "../../utils/floating-ui"; import { useT9n } from "../../controllers/useT9n"; import type { Tooltip } from "../tooltip/tooltip"; import type { ActionGroup } from "../action-group/action-group"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { logger } from "../../utils/logger"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, SLOTS } from "./resources"; @@ -53,6 +53,8 @@ export class ActionPad extends LitElement { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -101,8 +103,9 @@ export class ActionPad extends LitElement { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/action/action.tsx b/packages/calcite-components/src/components/action/action.tsx index 72712261a4f..6e57f13df20 100644 --- a/packages/calcite-components/src/components/action/action.tsx +++ b/packages/calcite-components/src/components/action/action.tsx @@ -7,13 +7,13 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { getIconScale } from "../../utils/component"; import { Alignment, Appearance, Scale, Width } from "../interfaces"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Tooltip } from "../tooltip/tooltip"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, SLOTS, IDS } from "./resources"; import { styles } from "./action.scss"; @@ -54,6 +54,8 @@ export class Action extends LitElement implements InteractiveComponent { */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region Public Properties @@ -135,8 +137,9 @@ export class Action extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.buttonEl.value?.focus(); + return this.focusSetter(() => { + return this.buttonEl.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/alert/alert.tsx b/packages/calcite-components/src/components/alert/alert.tsx index b003856ff7c..0209855f2ad 100644 --- a/packages/calcite-components/src/components/alert/alert.tsx +++ b/packages/calcite-components/src/components/alert/alert.tsx @@ -10,20 +10,16 @@ import { JsxNode, stringOrBoolean, } from "@arcgis/lumina"; -import { - focusFirstTabbable, - setRequestedIcon, - slotChangeHasAssignedElement, -} from "../../utils/dom"; +import { setRequestedIcon, slotChangeHasAssignedElement } from "../../utils/dom"; import { MenuPlacement } from "../../utils/floating-ui"; import { getIconScale } from "../../utils/component"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem, NumberStringFormat } from "../../utils/locale"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; import { Kind, Scale } from "../interfaces"; import { KindIcons } from "../resources"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { AlertDuration, AlertQueue } from "./interfaces"; import { CSS, DURATIONS, SLOTS } from "./resources"; @@ -79,6 +75,8 @@ export class Alert extends LitElement implements OpenCloseComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -174,8 +172,9 @@ export class Alert extends LitElement implements OpenCloseComponent { */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/autocomplete/autocomplete.tsx b/packages/calcite-components/src/components/autocomplete/autocomplete.tsx index a3787948bb5..7b714c481da 100644 --- a/packages/calcite-components/src/components/autocomplete/autocomplete.tsx +++ b/packages/calcite-components/src/components/autocomplete/autocomplete.tsx @@ -54,7 +54,7 @@ import type { AutocompleteItemGroup } from "../autocomplete-item-group/autocompl import type { Label } from "../label/label"; import { Validation } from "../functional/Validation"; import { createObserver } from "../../utils/observers"; -import { componentFocusable } from "../../utils/component"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles } from "./autocomplete.scss"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, IDS, SLOTS } from "./resources"; @@ -131,6 +131,8 @@ export class Autocomplete private mutationObserver = createObserver("mutation", () => this.getAllItemsDebounced()); + private focusSetter = useSetFocus()(this); + private resizeObserver = createObserver("resize", () => { this.setFloatingElSize(); }); @@ -389,9 +391,9 @@ export class Autocomplete */ @method() async setFocus(): Promise { - await componentFocusable(this); - - return this.referenceEl.setFocus(); + return this.focusSetter(() => { + return this.referenceEl; + }); } //#endregion diff --git a/packages/calcite-components/src/components/block-group/block-group.tsx b/packages/calcite-components/src/components/block-group/block-group.tsx index aed7b9bb9b7..9eb86eb0be7 100755 --- a/packages/calcite-components/src/components/block-group/block-group.tsx +++ b/packages/calcite-components/src/components/block-group/block-group.tsx @@ -14,13 +14,13 @@ import { disconnectSortableComponent, SortableComponent, } from "../../utils/sortableComponent"; -import { componentFocusable } from "../../utils/component"; import { MoveEventDetail, MoveTo, ReorderEventDetail } from "../sort-handle/interfaces"; import { DEBOUNCE } from "../../utils/resources"; import { Block } from "../block/block"; -import { focusFirstTabbable, getRootNode } from "../../utils/dom"; +import { getRootNode } from "../../utils/dom"; import { guid } from "../../utils/guid"; import { isBlock } from "../block/utils"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { useCancelable } from "../../controllers/useCancelable"; import { blockGroupSelector, blockSelector, CSS } from "./resources"; import { styles } from "./block-group.scss"; @@ -61,6 +61,8 @@ export class BlockGroup extends LitElement implements InteractiveComponent, Sort private updateBlockItemsDebounced = debounce(this.updateBlockItems, DEBOUNCE.nextTick); + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -115,9 +117,9 @@ export class BlockGroup extends LitElement implements InteractiveComponent, Sort */ @method() async setFocus(): Promise { - await componentFocusable(this); - - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } // #endregion diff --git a/packages/calcite-components/src/components/block-section/block-section.tsx b/packages/calcite-components/src/components/block-section/block-section.tsx index c9c956a9e1e..ddd123d10a5 100644 --- a/packages/calcite-components/src/components/block-section/block-section.tsx +++ b/packages/calcite-components/src/components/block-section/block-section.tsx @@ -1,12 +1,11 @@ // @ts-strict-ignore import { LitElement, property, createEvent, Fragment, h, method, JsxNode } from "@arcgis/lumina"; -import { focusFirstTabbable } from "../../utils/dom"; import { isActivationKey } from "../../utils/key"; import { FlipContext, Status } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import { logger } from "../../utils/logger"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { BlockSectionToggleDisplay } from "./interfaces"; import { CSS, ICONS, IDS } from "./resources"; @@ -35,6 +34,8 @@ export class BlockSection extends LitElement { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region Public Properties @@ -98,8 +99,9 @@ export class BlockSection extends LitElement { /** Sets focus on the component's first tabbable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/block/block.tsx b/packages/calcite-components/src/components/block/block.tsx index e9521c49416..b4312572210 100644 --- a/packages/calcite-components/src/components/block/block.tsx +++ b/packages/calcite-components/src/components/block/block.tsx @@ -1,7 +1,7 @@ // @ts-strict-ignore import { PropertyValues } from "lit"; import { LitElement, property, createEvent, h, method, state, JsxNode } from "@arcgis/lumina"; -import { focusFirstTabbable, slotChangeHasAssignedElement } from "../../utils/dom"; +import { slotChangeHasAssignedElement } from "../../utils/dom"; import { InteractiveComponent, InteractiveContainer, @@ -9,7 +9,6 @@ import { } from "../../utils/interactive"; import { Heading, HeadingLevel } from "../functional/Heading"; import { FlipContext, Position, Status } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; import { defaultEndMenuPlacement, @@ -22,6 +21,7 @@ import { useT9n } from "../../controllers/useT9n"; import { logger } from "../../utils/logger"; import { MoveTo } from "../sort-handle/interfaces"; import { SortHandle } from "../sort-handle/sort-handle"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles as sortableStyles } from "../../assets/styles/_sortable.scss"; import { CSS, ICONS, IDS, SLOTS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -63,6 +63,8 @@ export class Block extends LitElement implements InteractiveComponent, OpenClose */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -203,8 +205,9 @@ export class Block extends LitElement implements InteractiveComponent, OpenClose /** Sets focus on the component's first tabbable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/button/button.tsx b/packages/calcite-components/src/components/button/button.tsx index 23b9a31e78b..5e1a2bba3d7 100644 --- a/packages/calcite-components/src/components/button/button.tsx +++ b/packages/calcite-components/src/components/button/button.tsx @@ -20,7 +20,6 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { getIconScale } from "../../utils/component"; import { Appearance, FlipContext, Kind, Scale, Width } from "../interfaces"; @@ -28,6 +27,7 @@ import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Label } from "../label/label"; import { hasVisibleContent } from "../../utils/dom"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { ButtonAlignment } from "./interfaces"; import { CSS } from "./resources"; @@ -75,6 +75,8 @@ export class Button private resizeObserver = createObserver("resize", () => this.setTooltipText()); + private focusSetter = useSetFocus()(this); + /** * Made into a prop for testing purposes only * @@ -191,9 +193,7 @@ export class Button /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.childEl?.focus(); + return this.focusSetter(() => this.childEl); } //#endregion diff --git a/packages/calcite-components/src/components/card-group/card-group.tsx b/packages/calcite-components/src/components/card-group/card-group.tsx index 31e567a923e..c31ba705b27 100644 --- a/packages/calcite-components/src/components/card-group/card-group.tsx +++ b/packages/calcite-components/src/components/card-group/card-group.tsx @@ -2,7 +2,7 @@ import { PropertyValues } from "lit"; import { createRef } from "lit-html/directives/ref.js"; import { LitElement, property, createEvent, h, method, JsxNode } from "@arcgis/lumina"; -import { focusElement, focusElementInGroup } from "../../utils/dom"; +import { focusElementInGroup } from "../../utils/dom"; import { InteractiveComponent, InteractiveContainer, @@ -10,6 +10,7 @@ import { } from "../../utils/interactive"; import { SelectionMode } from "../interfaces"; import type { Card } from "../card/card"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles } from "./card-group.scss"; import { CSS } from "./resources"; @@ -33,6 +34,8 @@ export class CardGroup extends LitElement implements InteractiveComponent { private slotRefEl = createRef(); + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -67,10 +70,9 @@ export class CardGroup extends LitElement implements InteractiveComponent { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await this.componentOnReady(); - if (!this.disabled) { - focusElement(this.items[0]); - } + return this.focusSetter(() => { + return this.items[0]; + }); } // #endregion @@ -111,21 +113,28 @@ export class CardGroup extends LitElement implements InteractiveComponent { // #endregion // #region Private Methods + private calciteInternalCardKeyEventListener(event: KeyboardEvent): void { if (event.composedPath().includes(this.el)) { const interactiveItems = this.items.filter((el) => !el.disabled); switch (event.detail["key"]) { case "ArrowRight": - focusElementInGroup(interactiveItems, event.target as Card["el"], "next"); + focusElementInGroup(interactiveItems, event.target as Card["el"], "next", true, false); break; case "ArrowLeft": - focusElementInGroup(interactiveItems, event.target as Card["el"], "previous"); + focusElementInGroup( + interactiveItems, + event.target as Card["el"], + "previous", + true, + false, + ); break; case "Home": - focusElementInGroup(interactiveItems, event.target as Card["el"], "first"); + focusElementInGroup(interactiveItems, event.target as Card["el"], "first", true, false); break; case "End": - focusElementInGroup(interactiveItems, event.target as Card["el"], "last"); + focusElementInGroup(interactiveItems, event.target as Card["el"], "last", true, false); break; } } diff --git a/packages/calcite-components/src/components/card/card.tsx b/packages/calcite-components/src/components/card/card.tsx index c5dfa0bd947..1a221936347 100644 --- a/packages/calcite-components/src/components/card/card.tsx +++ b/packages/calcite-components/src/components/card/card.tsx @@ -11,7 +11,6 @@ import { ToEvents, } from "@arcgis/lumina"; import { slotChangeHasAssignedElement } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; import { LogicalFlowPosition, SelectionMode } from "../interfaces"; import { InteractiveComponent, @@ -22,6 +21,7 @@ import { isActivationKey } from "../../utils/key"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Checkbox } from "../checkbox/checkbox"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS, SLOTS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; import { styles } from "./card.scss"; @@ -60,6 +60,8 @@ export class Card extends LitElement implements InteractiveComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -127,10 +129,9 @@ export class Card extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - if (!this.disabled) { - this.containerEl.value?.focus(); - } + return this.focusSetter(() => { + return this.containerEl.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/carousel/carousel.tsx b/packages/calcite-components/src/components/carousel/carousel.tsx index e16a31eb594..e30ace6d21d 100644 --- a/packages/calcite-components/src/components/carousel/carousel.tsx +++ b/packages/calcite-components/src/components/carousel/carousel.tsx @@ -13,13 +13,13 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { breakpoints } from "../../utils/responsive"; import { getRoundRobinIndex } from "../../utils/array"; import { useT9n } from "../../controllers/useT9n"; import type { Action } from "../action/action"; import type { CarouselItem } from "../carousel-item/carousel-item"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { centerItemsByBreakpoint, CSS, DURATION, ICONS, IDS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; import { ArrowType, AutoplayType } from "./interfaces"; @@ -91,6 +91,8 @@ export class Carousel extends LitElement implements InteractiveComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -175,8 +177,9 @@ export class Carousel extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.container?.focus(); + return this.focusSetter(() => { + return this.container; + }); } /** Stop the carousel. If `autoplay` is not enabled (initialized either to `true` or `"paused"`), these methods will have no effect. */ diff --git a/packages/calcite-components/src/components/checkbox/checkbox.tsx b/packages/calcite-components/src/components/checkbox/checkbox.tsx index 6d4432c775b..103f0258979 100644 --- a/packages/calcite-components/src/components/checkbox/checkbox.tsx +++ b/packages/calcite-components/src/components/checkbox/checkbox.tsx @@ -16,10 +16,10 @@ import { } from "../../utils/interactive"; import { isActivationKey } from "../../utils/key"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { Scale, Status } from "../interfaces"; import { CSS_UTILITY } from "../../utils/resources"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS } from "./resources"; import { styles } from "./checkbox.scss"; @@ -59,6 +59,8 @@ export class Checkbox private toggleEl = createRef(); + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -144,9 +146,9 @@ export class Checkbox /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.toggleEl.value?.focus(); + return this.focusSetter(() => { + return this.toggleEl.value; + }); } // #endregion diff --git a/packages/calcite-components/src/components/chip-group/chip-group.tsx b/packages/calcite-components/src/components/chip-group/chip-group.tsx index b3859f22bc0..f3060a380b3 100644 --- a/packages/calcite-components/src/components/chip-group/chip-group.tsx +++ b/packages/calcite-components/src/components/chip-group/chip-group.tsx @@ -2,15 +2,19 @@ import { PropertyValues } from "lit"; import { createRef } from "lit-html/directives/ref.js"; import { LitElement, property, createEvent, h, method, JsxNode } from "@arcgis/lumina"; -import { focusElementInGroup, slotChangeGetAssignedElements } from "../../utils/dom"; +import { + focusElementInGroup, + FocusElementInGroupDestination, + slotChangeGetAssignedElements, +} from "../../utils/dom"; import { InteractiveComponent, InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; import { Scale, SelectionMode } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import type { Chip } from "../chip/chip"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles } from "./chip-group.scss"; declare global { @@ -32,6 +36,8 @@ export class ChipGroup extends LitElement implements InteractiveComponent { private slotRefEl = createRef(); + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -79,10 +85,9 @@ export class ChipGroup extends LitElement implements InteractiveComponent { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - if (!this.disabled) { - return (this.selectedItems[0] || this.items[0])?.setFocus(); - } + return this.focusSetter(() => { + return this.selectedItems[0] || this.items[0]; + }); } // #endregion @@ -122,22 +127,20 @@ export class ChipGroup extends LitElement implements InteractiveComponent { // #endregion // #region Private Methods + private calciteInternalChipKeyEventListener(event: CustomEvent): void { if (event.composedPath().includes(this.el)) { - const interactiveItems = this.items?.filter((el) => !el.disabled); - switch (event.detail.key) { - case "ArrowRight": - focusElementInGroup(interactiveItems, event.detail.target, "next"); - break; - case "ArrowLeft": - focusElementInGroup(interactiveItems, event.detail.target, "previous"); - break; - case "Home": - focusElementInGroup(interactiveItems, event.detail.target, "first"); - break; - case "End": - focusElementInGroup(interactiveItems, event.detail.target, "last"); - break; + const destinationFromKey: Record = { + ArrowRight: "next", + ArrowLeft: "previous", + Home: "first", + End: "last", + }; + const destination = destinationFromKey[event.detail.key]; + + if (destination) { + const interactiveItems = this.items?.filter((el) => !el.disabled); + focusElementInGroup(interactiveItems, event.detail.target, destination, true, true, true); } } event.stopPropagation(); @@ -147,11 +150,11 @@ export class ChipGroup extends LitElement implements InteractiveComponent { const item = event.target as Chip["el"]; if (this.items?.includes(item)) { if (this.items?.indexOf(item) > 0) { - focusElementInGroup(this.items, item, "previous"); + focusElementInGroup(this.items, item, "previous", false, false); } else if (this.items?.indexOf(item) === 0) { - focusElementInGroup(this.items, item, "next"); + focusElementInGroup(this.items, item, "next", false, false); } else { - focusElementInGroup(this.items, item, "first"); + focusElementInGroup(this.items, item, "first", false, false); } } this.items = this.items?.filter((el) => el !== item); diff --git a/packages/calcite-components/src/components/chip/chip.tsx b/packages/calcite-components/src/components/chip/chip.tsx index 5120c79b73b..28746b6d246 100644 --- a/packages/calcite-components/src/components/chip/chip.tsx +++ b/packages/calcite-components/src/components/chip/chip.tsx @@ -4,7 +4,6 @@ import { createRef } from "lit-html/directives/ref.js"; import { LitElement, property, createEvent, h, method, state, JsxNode } from "@arcgis/lumina"; import { slotChangeHasAssignedElement } from "../../utils/dom"; import { Appearance, Kind, Scale, SelectionMode } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import { InteractiveComponent, InteractiveContainer, @@ -15,6 +14,7 @@ import { getIconScale } from "../../utils/component"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { ChipGroup } from "../chip-group/chip-group"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, SLOTS, ICONS } from "./resources"; import { styles } from "./chip.scss"; @@ -49,6 +49,8 @@ export class Chip extends LitElement implements InteractiveComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -136,12 +138,13 @@ export class Chip extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - if (!this.disabled && this.interactive) { - this.containerEl.value?.focus(); - } else if (!this.disabled && this.closable) { - this.closeButtonEl.value?.focus(); - } + return this.focusSetter(() => { + if (this.interactive) { + return this.containerEl.value; + } else if (this.closable) { + return this.closeButtonEl.value; + } + }); } //#endregion diff --git a/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.tsx b/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.tsx index ca6eb747103..463e95a8310 100644 --- a/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.tsx +++ b/packages/calcite-components/src/components/color-picker-hex-input/color-picker-hex-input.tsx @@ -15,13 +15,12 @@ import { opacityToAlpha, rgbToHex, } from "../color-picker/utils"; -import { focusElement } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem } from "../../utils/locale"; import { OPACITY_LIMITS } from "../color-picker/resources"; import type { InputNumber } from "../input-number/input-number"; import type { InputText } from "../input-text/input-text"; import type { ColorPicker } from "../color-picker/color-picker"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS } from "./resources"; import { styles } from "./color-picker-hex-input.scss"; @@ -48,6 +47,8 @@ export class ColorPickerHexInput extends LitElement { private previousNonNullValue: string; + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -103,9 +104,9 @@ export class ColorPickerHexInput extends LitElement { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - return focusElement(this.hexInputNode); + return this.focusSetter(() => { + return this.hexInputNode; + }); } // #endregion @@ -156,6 +157,7 @@ export class ColorPickerHexInput extends LitElement { // #endregion // #region Private Methods + private onHexInputBlur(): void { const node = this.hexInputNode; const inputValue = node.value; diff --git a/packages/calcite-components/src/components/color-picker/color-picker.tsx b/packages/calcite-components/src/components/color-picker/color-picker.tsx index f8624f95eff..72e9d9181d9 100644 --- a/packages/calcite-components/src/components/color-picker/color-picker.tsx +++ b/packages/calcite-components/src/components/color-picker/color-picker.tsx @@ -3,12 +3,7 @@ import Color, { type ColorInstance } from "color"; import { throttle } from "lodash-es"; import { PropertyValues } from "lit"; import { createEvent, h, JsxNode, LitElement, method, property, state } from "@arcgis/lumina"; -import { - Direction, - focusFirstTabbable, - getElementDir, - isPrimaryPointerButton, -} from "../../utils/dom"; +import { Direction, getElementDir, isPrimaryPointerButton } from "../../utils/dom"; import { Dimensions, Scale } from "../interfaces"; import { InteractiveComponent, @@ -16,7 +11,6 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { isActivationKey } from "../../utils/key"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem } from "../../utils/locale"; import { clamp, closeToRangeEdge, remap } from "../../utils/math"; import { useT9n } from "../../controllers/useT9n"; @@ -25,6 +19,7 @@ import type { InputNumber } from "../input-number/input-number"; import type { ColorPickerSwatch } from "../color-picker-swatch/color-picker-swatch"; import type { ColorPickerHexInput } from "../color-picker-hex-input/color-picker-hex-input"; import { createObserver } from "../../utils/observers"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { alphaCompatible, alphaToOpacity, @@ -254,6 +249,8 @@ export class ColorPicker extends LitElement implements InteractiveComponent { }; }; + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -378,14 +375,14 @@ export class ColorPicker extends LitElement implements InteractiveComponent { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } - //#endregion + // #endregion - //#region Events + // #region Events /** Fires when the color value has changed. */ calciteColorPickerChange = createEvent({ cancelable: false }); diff --git a/packages/calcite-components/src/components/combobox/combobox.tsx b/packages/calcite-components/src/components/combobox/combobox.tsx index 263ff4df5c5..ae60c990c26 100644 --- a/packages/calcite-components/src/components/combobox/combobox.tsx +++ b/packages/calcite-components/src/components/combobox/combobox.tsx @@ -14,7 +14,7 @@ import { stringOrBoolean, } from "@arcgis/lumina"; import { filter } from "../../utils/filter"; -import { getElementWidth, getTextWidth } from "../../utils/dom"; +import { focusElement, getElementWidth, getTextWidth } from "../../utils/dom"; import { connectFloatingUI, defaultMenuPlacement, @@ -44,7 +44,6 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; import { DEBOUNCE } from "../../utils/resources"; @@ -58,6 +57,7 @@ import type { Chip } from "../chip/chip"; import type { ComboboxItemGroup as HTMLCalciteComboboxItemGroupElement } from "../combobox-item-group/combobox-item-group"; import type { ComboboxItem as HTMLCalciteComboboxItemElement } from "../combobox-item/combobox-item"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { useCancelable } from "../../controllers/useCancelable"; import T9nStrings from "./assets/t9n/messages.en.json"; import { ComboboxChildElement, GroupData, ItemData, SelectionDisplay } from "./interfaces"; @@ -245,6 +245,8 @@ export class Combobox */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -508,11 +510,11 @@ export class Combobox /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.textInput.value?.focus(); - this.activeChipIndex = -1; - this.activeItemIndex = -1; + return this.focusSetter(() => { + this.activeChipIndex = -1; + this.activeItemIndex = -1; + return this.textInput.value; + }); } //#endregion @@ -628,10 +630,6 @@ export class Combobox //#region Private Methods - private emitComboboxChange(): void { - this.calciteComboboxChange.emit(); - } - private get effectiveFilterProps(): string[] { if (!this.filterProps) { return ["description", "label", "metadata", "shortHeading", "textLabel"]; @@ -640,6 +638,10 @@ export class Combobox return this.filterProps.filter((prop) => prop !== "el"); } + private emitComboboxChange(): void { + this.calciteComboboxChange.emit(); + } + private get showingInlineIcon(): boolean { const { placeholderIcon, selectionMode, selectedItems, open } = this; const selectedItem = selectedItems[0]; @@ -1430,7 +1432,7 @@ export class Combobox const newIndex = this.activeChipIndex + 1; if (newIndex > last) { this.activeChipIndex = -1; - this.setFocus(); + focusElement(this.textInput.value); } else { this.activeChipIndex = newIndex; this.focusChip(); diff --git a/packages/calcite-components/src/components/date-picker-day/date-picker-day.tsx b/packages/calcite-components/src/components/date-picker-day/date-picker-day.tsx index 8f5610c3ac4..f1945070311 100644 --- a/packages/calcite-components/src/components/date-picker-day/date-picker-day.tsx +++ b/packages/calcite-components/src/components/date-picker-day/date-picker-day.tsx @@ -18,8 +18,8 @@ import { import { isActivationKey } from "../../utils/key"; import { numberStringFormatter } from "../../utils/locale"; import { Scale } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import type { DatePicker } from "../date-picker/date-picker"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles } from "./date-picker-day.scss"; import { CSS } from "./resources"; @@ -40,6 +40,8 @@ export class DatePickerDay extends LitElement implements InteractiveComponent { private parentDatePickerEl: DatePicker["el"]; + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -105,8 +107,9 @@ export class DatePickerDay extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.el.focus(); + return this.focusSetter(() => { + return this.el; + }); } // #endregion diff --git a/packages/calcite-components/src/components/date-picker/date-picker.tsx b/packages/calcite-components/src/components/date-picker/date-picker.tsx index bf39aed48da..58f32bf76d2 100644 --- a/packages/calcite-components/src/components/date-picker/date-picker.tsx +++ b/packages/calcite-components/src/components/date-picker/date-picker.tsx @@ -22,11 +22,10 @@ import { prevMonth, sameDate, } from "../../utils/date"; -import { componentFocusable } from "../../utils/component"; import { getDateTimeFormat, NumberingSystem, numberStringFormatter } from "../../utils/locale"; import { HeadingLevel } from "../functional/Heading"; -import { focusFirstTabbable } from "../../utils/dom"; import { useT9n } from "../../controllers/useT9n"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { DATE_PICKER_FORMAT_OPTIONS, HEADING_LEVEL, CSS } from "./resources"; import { DateLocaleData, getLocaleData, getValueAsDateRange } from "./utils"; @@ -56,6 +55,8 @@ export class DatePicker extends LitElement { */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -157,8 +158,9 @@ export class DatePicker extends LitElement { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/dialog/dialog.tsx b/packages/calcite-components/src/components/dialog/dialog.tsx index 1b869a19c07..3fe9b0831de 100644 --- a/packages/calcite-components/src/components/dialog/dialog.tsx +++ b/packages/calcite-components/src/components/dialog/dialog.tsx @@ -4,8 +4,7 @@ import type { DragEvent, Interactable, ResizeEvent } from "@interactjs/types"; import { PropertyValues } from "lit"; import { createRef } from "lit-html/directives/ref.js"; import { createEvent, h, JsxNode, LitElement, method, property, state } from "@arcgis/lumina"; -import { focusFirstTabbable, getStylePixelValue } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; +import { getStylePixelValue } from "../../utils/dom"; import { createObserver } from "../../utils/observers"; import { getDimensionClass } from "../../utils/dynamicClasses"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; @@ -18,6 +17,7 @@ import type { Panel } from "../panel/panel"; import { FocusTrapOptions, useFocusTrap } from "../../controllers/useFocusTrap"; import { usePreventDocumentScroll } from "../../controllers/usePreventDocumentScroll"; import { resizeShiftStep } from "../../utils/resources"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { IconNameOrString } from "../icon/interfaces"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, initialDragPosition, initialResizePosition, SLOTS } from "./resources"; @@ -101,6 +101,8 @@ export class Dialog extends LitElement implements OpenCloseComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -267,8 +269,9 @@ export class Dialog extends LitElement implements OpenCloseComponent { */ @method() async setFocus(): Promise { - await componentFocusable(this); - return this.panelEl.value?.setFocus() ?? focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.panelEl.value ?? this.el; + }); } /** diff --git a/packages/calcite-components/src/components/dropdown-item/dropdown-item.tsx b/packages/calcite-components/src/components/dropdown-item/dropdown-item.tsx index 0b55f6e01e0..bb14a733a0b 100644 --- a/packages/calcite-components/src/components/dropdown-item/dropdown-item.tsx +++ b/packages/calcite-components/src/components/dropdown-item/dropdown-item.tsx @@ -13,7 +13,6 @@ import { toAriaBoolean } from "../../utils/dom"; import { ItemKeyboardEvent } from "../dropdown/interfaces"; import { RequestedItem } from "../dropdown-group/interfaces"; import { FlipContext, Scale, SelectionMode } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import { getIconScale } from "../../utils/component"; import { InteractiveComponent, @@ -22,6 +21,7 @@ import { } from "../../utils/interactive"; import { IconNameOrString } from "../icon/interfaces"; import type { DropdownGroup } from "../dropdown-group/dropdown-group"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS } from "./resources"; import { styles } from "./dropdown-item.scss"; @@ -53,6 +53,8 @@ export class DropdownItem extends LitElement implements InteractiveComponent { /** requested item */ private requestedDropdownItem: DropdownItem["el"]; + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -112,9 +114,9 @@ export class DropdownItem extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.el?.focus(); + return this.focusSetter(() => { + return this.el; + }); } // #endregion diff --git a/packages/calcite-components/src/components/dropdown/dropdown.tsx b/packages/calcite-components/src/components/dropdown/dropdown.tsx index 54163ec3f57..0ff53e03b2d 100644 --- a/packages/calcite-components/src/components/dropdown/dropdown.tsx +++ b/packages/calcite-components/src/components/dropdown/dropdown.tsx @@ -1,7 +1,7 @@ // @ts-strict-ignore import { PropertyValues } from "lit"; import { createEvent, h, JsxNode, LitElement, method, property } from "@arcgis/lumina"; -import { focusElement, focusElementInGroup, focusFirstTabbable } from "../../utils/dom"; +import { focusElement, focusElementInGroup } from "../../utils/dom"; import { connectFloatingUI, defaultMenuPlacement, @@ -22,7 +22,6 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { isActivationKey } from "../../utils/key"; -import { componentFocusable } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; import { getDimensionClass } from "../../utils/dynamicClasses"; @@ -30,6 +29,7 @@ import { RequestedItem } from "../dropdown-group/interfaces"; import { Scale, Width } from "../interfaces"; import type { DropdownItem } from "../dropdown-item/dropdown-item"; import type { DropdownGroup } from "../dropdown-group/dropdown-group"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { ItemKeyboardEvent } from "./interfaces"; import { CSS, SLOTS, IDS } from "./resources"; import { styles } from "./dropdown.scss"; @@ -87,6 +87,8 @@ export class Dropdown /** trigger elements */ private triggers: HTMLElement[]; + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -202,8 +204,9 @@ export class Dropdown /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.referenceEl); + return this.focusSetter(() => { + return this.referenceEl; + }); } // #endregion diff --git a/packages/calcite-components/src/components/fab/fab.tsx b/packages/calcite-components/src/components/fab/fab.tsx index f0824c94cbf..cbe93ddf889 100755 --- a/packages/calcite-components/src/components/fab/fab.tsx +++ b/packages/calcite-components/src/components/fab/fab.tsx @@ -1,16 +1,15 @@ // @ts-strict-ignore import { createRef } from "lit-html/directives/ref.js"; import { LitElement, property, h, method, JsxNode } from "@arcgis/lumina"; -import { focusElement } from "../../utils/dom"; import { InteractiveComponent, InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable } from "../../utils/component"; import { Appearance, Kind, Scale } from "../interfaces"; import { IconNameOrString } from "../icon/interfaces"; import type { Button } from "../button/button"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS } from "./resources"; import { styles } from "./fab.scss"; @@ -31,6 +30,8 @@ export class Fab extends LitElement implements InteractiveComponent { private buttonEl = createRef(); + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -77,9 +78,9 @@ export class Fab extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - focusElement(this.buttonEl.value); + return this.focusSetter(() => { + return this.buttonEl.value; + }); } // #endregion diff --git a/packages/calcite-components/src/components/filter/filter.tsx b/packages/calcite-components/src/components/filter/filter.tsx index eecc7fd6913..f5246b68422 100644 --- a/packages/calcite-components/src/components/filter/filter.tsx +++ b/packages/calcite-components/src/components/filter/filter.tsx @@ -9,12 +9,12 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable } from "../../utils/component"; import { Scale } from "../interfaces"; import { DEBOUNCE } from "../../utils/resources"; import { useCancelable } from "../../controllers/useCancelable"; import { useT9n } from "../../controllers/useT9n"; import type { Input } from "../input/input"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, ICONS } from "./resources"; import { styles } from "./filter.scss"; @@ -55,6 +55,8 @@ export class Filter extends LitElement implements InteractiveComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region Public Properties @@ -132,9 +134,9 @@ export class Filter extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - return this.textInput.value?.setFocus(); + return this.focusSetter(() => { + return this.textInput.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/flow-item/flow-item.tsx b/packages/calcite-components/src/components/flow-item/flow-item.tsx index 66b11a27fce..7f61f88e511 100644 --- a/packages/calcite-components/src/components/flow-item/flow-item.tsx +++ b/packages/calcite-components/src/components/flow-item/flow-item.tsx @@ -7,7 +7,6 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable } from "../../utils/component"; import { HeadingLevel } from "../functional/Heading"; import { SLOTS as PANEL_SLOTS } from "../panel/resources"; import { OverlayPositioning } from "../../utils/floating-ui"; @@ -15,6 +14,7 @@ import { CollapseDirection, Scale } from "../interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Panel } from "../panel/panel"; import type { Action } from "../action/action"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { IconNameOrString } from "../icon/interfaces"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, ICONS, SLOTS } from "./resources"; @@ -62,6 +62,8 @@ export class FlowItem extends LitElement implements InteractiveComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region Public Properties @@ -168,15 +170,9 @@ export class FlowItem extends LitElement implements InteractiveComponent { */ @method() async setFocus(): Promise { - await componentFocusable(this); - - const { backButtonEl, containerEl } = this; - - if (backButtonEl) { - return backButtonEl.setFocus(); - } else if (containerEl) { - return containerEl.setFocus(); - } + return this.focusSetter(() => { + return this.backButtonEl || this.containerEl; + }); } //#endregion diff --git a/packages/calcite-components/src/components/flow/flow.tsx b/packages/calcite-components/src/components/flow/flow.tsx index 17a3867eb86..0b121523f32 100755 --- a/packages/calcite-components/src/components/flow/flow.tsx +++ b/packages/calcite-components/src/components/flow/flow.tsx @@ -2,9 +2,9 @@ import { PropertyValues } from "lit"; import { LitElement, property, h, method, state, JsxNode } from "@arcgis/lumina"; import { createObserver } from "../../utils/observers"; -import { componentFocusable } from "../../utils/component"; import { whenAnimationDone } from "../../utils/dom"; import type { FlowItem } from "../flow-item/flow-item"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { FlowDirection, FlowItemLikeElement } from "./interfaces"; import { CSS } from "./resources"; import { styles } from "./flow.scss"; @@ -35,6 +35,8 @@ export class Flow extends LitElement { private selectedIndex = -1; + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -95,12 +97,9 @@ export class Flow extends LitElement { */ @method() async setFocus(): Promise { - await componentFocusable(this); - - const { items } = this; - const selectedItem = items[this.selectedIndex]; - - return selectedItem?.setFocus(); + return this.focusSetter(() => { + return this.items[this.selectedIndex]; + }); } // #endregion diff --git a/packages/calcite-components/src/components/handle/handle.tsx b/packages/calcite-components/src/components/handle/handle.tsx index 77906fe75d0..cb4e4daabd6 100644 --- a/packages/calcite-components/src/components/handle/handle.tsx +++ b/packages/calcite-components/src/components/handle/handle.tsx @@ -2,7 +2,6 @@ import { PropertyValues } from "lit"; import { createRef } from "lit-html/directives/ref.js"; import { LitElement, property, createEvent, h, method, JsxNode } from "@arcgis/lumina"; -import { componentFocusable } from "../../utils/component"; import { InteractiveComponent, InteractiveContainer, @@ -10,6 +9,7 @@ import { } from "../../utils/interactive"; import { useT9n } from "../../controllers/useT9n"; import { logger } from "../../utils/logger"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { HandleChange, HandleNudge } from "./interfaces"; import { CSS, ICONS, SUBSTITUTIONS } from "./resources"; @@ -42,6 +42,8 @@ export class Handle extends LitElement implements InteractiveComponent { */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region Public Properties @@ -90,9 +92,9 @@ export class Handle extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.handleButton.value?.focus(); + return this.focusSetter(() => { + return this.handleButton.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/inline-editable/inline-editable.tsx b/packages/calcite-components/src/components/inline-editable/inline-editable.tsx index fc200fecb07..575e2424be9 100644 --- a/packages/calcite-components/src/components/inline-editable/inline-editable.tsx +++ b/packages/calcite-components/src/components/inline-editable/inline-editable.tsx @@ -8,13 +8,13 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { Scale } from "../interfaces"; import { slotChangeGetAssignedElements } from "../../utils/dom"; import { useT9n } from "../../controllers/useT9n"; import type { Input } from "../input/input"; import type { Label } from "../label/label"; import type { Button } from "../button/button"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles } from "./inline-editable.scss"; import { CSS, ICONS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -60,6 +60,8 @@ export class InlineEditable extends LitElement implements InteractiveComponent, */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region Public Properties @@ -102,9 +104,9 @@ export class InlineEditable extends LitElement implements InteractiveComponent, /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.inputElement?.setFocus(); + return this.focusSetter(() => { + return this.inputElement; + }); } //#endregion diff --git a/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx b/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx index 170a378112f..afa61ded883 100644 --- a/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx +++ b/packages/calcite-components/src/components/input-date-picker/input-date-picker.tsx @@ -20,7 +20,6 @@ import { dateToISO, inRange, } from "../../utils/date"; -import { focusFirstTabbable } from "../../utils/dom"; import { connectFloatingUI, defaultMenuPlacement, @@ -49,7 +48,7 @@ import { } from "../../utils/interactive"; import { numberKeys } from "../../utils/key"; import { connectLabel, disconnectLabel, LabelableComponent } from "../../utils/label"; -import { componentFocusable, getIconScale } from "../../utils/component"; +import { getIconScale } from "../../utils/component"; import { getDateFormatSupportedLocale, getSupportedLocale, @@ -70,6 +69,7 @@ import type { DatePicker } from "../date-picker/date-picker"; import type { InputText } from "../input-text/input-text"; import type { Label } from "../label/label"; import type { Input } from "../input/input"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles } from "./input-date-picker.scss"; import { CSS, ICONS, IDS, POSITION } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -173,6 +173,8 @@ export class InputDatePicker */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -371,8 +373,9 @@ export class InputDatePicker /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/input-number/input-number.tsx b/packages/calcite-components/src/components/input-number/input-number.tsx index a20263e2d6b..12bc303ea79 100644 --- a/packages/calcite-components/src/components/input-number/input-number.tsx +++ b/packages/calcite-components/src/components/input-number/input-number.tsx @@ -31,7 +31,6 @@ import { } from "../../utils/interactive"; import { numberKeys } from "../../utils/key"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem, numberStringFormatter } from "../../utils/locale"; import { addLocalizedTrailingDecimalZeros, @@ -53,6 +52,7 @@ import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { InlineEditable } from "../inline-editable/inline-editable"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS, IDS, SLOTS, DIRECTION } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; import { styles } from "./input-number.scss"; @@ -138,6 +138,8 @@ export class InputNumber */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -361,9 +363,9 @@ export class InputNumber /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.childNumberEl?.focus(); + return this.focusSetter(() => { + return this.childNumberEl; + }); } //#endregion diff --git a/packages/calcite-components/src/components/input-text/input-text.tsx b/packages/calcite-components/src/components/input-text/input-text.tsx index c7641cfd1d6..417a07aab86 100644 --- a/packages/calcite-components/src/components/input-text/input-text.tsx +++ b/packages/calcite-components/src/components/input-text/input-text.tsx @@ -29,7 +29,6 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { CSS_UTILITY } from "../../utils/resources"; import { SetValueOrigin } from "../input/interfaces"; import { Alignment, Scale, Status } from "../interfaces"; @@ -40,6 +39,7 @@ import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { InlineEditable } from "../inline-editable/inline-editable"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, IDS, SLOTS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; import { styles } from "./input-text.scss"; @@ -114,6 +114,8 @@ export class InputText */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -294,9 +296,9 @@ export class InputText /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.childEl?.focus(); + return this.focusSetter(() => { + return this.childEl; + }); } //#endregion diff --git a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx index b68fbbc0255..a515fbea659 100644 --- a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx +++ b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx @@ -24,14 +24,13 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem } from "../../utils/locale"; import { HourFormat, TimePart } from "../../utils/time"; import { Scale, Status } from "../interfaces"; import { decimalPlaces } from "../../utils/math"; import { getIconScale } from "../../utils/component"; import { Validation } from "../functional/Validation"; -import { focusFirstTabbable, getElementDir } from "../../utils/dom"; +import { getElementDir } from "../../utils/dom"; import { IconNameOrString } from "../icon/interfaces"; import { syncHiddenFormInput } from "../input/common/input"; import { useT9n } from "../../controllers/useT9n"; @@ -39,6 +38,7 @@ import type { TimePicker } from "../time-picker/time-picker"; import type { Popover } from "../popover/popover"; import type { Label } from "../label/label"; import { isValidNumber } from "../../utils/number"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { TimeComponent, useTime } from "../../controllers/useTime"; import { styles } from "./input-time-picker.scss"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -77,6 +77,8 @@ export class InputTimePicker defaultValue: InputTimePicker["value"]; + private focusSetter = useSetFocus()(this); + formEl: HTMLFormElement; private fractionalSecondEl: HTMLSpanElement; @@ -238,8 +240,9 @@ export class InputTimePicker /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/input-time-zone/input-time-zone.tsx b/packages/calcite-components/src/components/input-time-zone/input-time-zone.tsx index 6bca2860e31..8171128e671 100644 --- a/packages/calcite-components/src/components/input-time-zone/input-time-zone.tsx +++ b/packages/calcite-components/src/components/input-time-zone/input-time-zone.tsx @@ -17,7 +17,6 @@ import { } from "../../utils/interactive"; import { Scale, Status } from "../interfaces"; import { OverlayPositioning } from "../../utils/floating-ui"; -import { componentFocusable } from "../../utils/component"; import { afterConnectDefaultValueSet, connectForm, @@ -30,6 +29,7 @@ import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Combobox } from "../combobox/combobox"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS } from "./resources"; import { createTimeZoneItems, @@ -86,6 +86,8 @@ export class InputTimeZone */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region Public Properties @@ -232,8 +234,9 @@ export class InputTimeZone /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - await this.comboboxEl.setFocus(); + return this.focusSetter(() => { + return this.comboboxEl; + }); } //#endregion diff --git a/packages/calcite-components/src/components/input/input.tsx b/packages/calcite-components/src/components/input/input.tsx index 1929d6a26ed..e852c825ab4 100644 --- a/packages/calcite-components/src/components/input/input.tsx +++ b/packages/calcite-components/src/components/input/input.tsx @@ -14,12 +14,7 @@ import { stringOrBoolean, } from "@arcgis/lumina"; import { useWatchAttributes } from "@arcgis/lumina/controllers"; -import { - focusFirstTabbable, - getElementDir, - isPrimaryPointerButton, - setRequestedIcon, -} from "../../utils/dom"; +import { getElementDir, isPrimaryPointerButton, setRequestedIcon } from "../../utils/dom"; import { Alignment, Scale, Status } from "../interfaces"; import { connectForm, @@ -37,7 +32,6 @@ import { } from "../../utils/interactive"; import { numberKeys } from "../../utils/key"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem, numberStringFormatter } from "../../utils/locale"; import { addLocalizedTrailingDecimalZeros, @@ -53,6 +47,7 @@ import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { InlineEditable } from "../inline-editable/inline-editable"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { InputPlacement, NumberNudgeDirection, SetValueOrigin } from "./interfaces"; import { CSS, IDS, INPUT_TYPE_ICONS, SLOTS, ICONS, DIRECTION } from "./resources"; @@ -146,6 +141,8 @@ export class Input */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -423,9 +420,9 @@ export class Input /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - focusFirstTabbable(this.type === "number" ? this.childNumberEl : this.childEl); + return this.focusSetter(() => { + return this.type === "number" ? this.childNumberEl : this.childEl; + }); } //#endregion diff --git a/packages/calcite-components/src/components/link/link.e2e.ts b/packages/calcite-components/src/components/link/link.e2e.ts index c72069c64bc..b79eb37f02c 100644 --- a/packages/calcite-components/src/components/link/link.e2e.ts +++ b/packages/calcite-components/src/components/link/link.e2e.ts @@ -234,8 +234,9 @@ describe("calcite-link", () => { it("keyboard without href", async () => { const element = await page.find("calcite-link"); - element.setProperty("href", undefined); const clickEvent = await element.spyOnEvent("click"); + element.setProperty("href", undefined); + await page.waitForChanges(); await element.callMethod("setFocus"); await page.waitForChanges(); diff --git a/packages/calcite-components/src/components/link/link.tsx b/packages/calcite-components/src/components/link/link.tsx index da8e7ccfb6d..e5824cb896b 100644 --- a/packages/calcite-components/src/components/link/link.tsx +++ b/packages/calcite-components/src/components/link/link.tsx @@ -1,16 +1,16 @@ // @ts-strict-ignore import { literal } from "lit-html/static.js"; import { LitElement, property, h, method, JsxNode, stringOrBoolean } from "@arcgis/lumina"; -import { focusElement, getElementDir } from "../../utils/dom"; +import { getElementDir } from "../../utils/dom"; import { InteractiveComponent, InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable } from "../../utils/component"; import { CSS_UTILITY } from "../../utils/resources"; import { FlipContext } from "../interfaces"; import { IconNameOrString } from "../icon/interfaces"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles } from "./link.scss"; import { CSS } from "./resources"; @@ -41,6 +41,8 @@ export class Link extends LitElement implements InteractiveComponent { /** the rendered child element */ private childEl: HTMLAnchorElement | HTMLButtonElement; + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -81,9 +83,9 @@ export class Link extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - focusElement(this.childEl); + return this.focusSetter(() => { + return this.childEl; + }); } // #endregion diff --git a/packages/calcite-components/src/components/list-item/list-item.tsx b/packages/calcite-components/src/components/list-item/list-item.tsx index 3b2df27da93..7d2db6b8c42 100644 --- a/packages/calcite-components/src/components/list-item/list-item.tsx +++ b/packages/calcite-components/src/components/list-item/list-item.tsx @@ -10,7 +10,6 @@ import { } from "../../utils/interactive"; import { SelectionMode, InteractionMode, Scale, FlipContext } from "../interfaces"; import { SelectionAppearance } from "../list/resources"; -import { componentFocusable } from "../../utils/component"; import { IconNameOrString } from "../icon/interfaces"; import { SortableComponentItem } from "../../utils/sortableComponent"; import { MoveTo } from "../sort-handle/interfaces"; @@ -21,6 +20,7 @@ import { getIconScale } from "../../utils/component"; import { ListDisplayMode } from "../list/interfaces"; import { logger } from "../../utils/logger"; import { styles as sortableStyles } from "../../assets/styles/_sortable.scss"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { getDepth, getListItemChildren, listSelector } from "./utils"; import { CSS, activeCellTestAttribute, ICONS, SLOTS } from "./resources"; @@ -72,6 +72,8 @@ export class ListItem extends LitElement implements InteractiveComponent, Sortab */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -258,24 +260,25 @@ export class ListItem extends LitElement implements InteractiveComponent, Sortab /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - const { - containerEl: { value: containerEl }, - parentListEl, - } = this; - const focusIndex = focusMap.get(parentListEl); - - if (typeof focusIndex === "number") { - const cells = this.getGridCells(); - if (cells[focusIndex]) { - this.focusCell(cells[focusIndex]); - } else { - containerEl?.focus(); + return this.focusSetter(() => { + const { + containerEl: { value: containerEl }, + parentListEl, + } = this; + const focusIndex = focusMap.get(parentListEl); + + if (typeof focusIndex === "number") { + const cells = this.getGridCells(); + if (cells[focusIndex]) { + this.focusCell(cells[focusIndex]); + return; + } else { + return { target: containerEl, includeContainer: true, strategy: "focusable" }; + } } - return; - } - containerEl?.focus(); + return { target: containerEl, includeContainer: true, strategy: "focusable" }; + }); } //#endregion diff --git a/packages/calcite-components/src/components/list/list.tsx b/packages/calcite-components/src/components/list/list.tsx index 7e515157fa8..9499134c262 100755 --- a/packages/calcite-components/src/components/list/list.tsx +++ b/packages/calcite-components/src/components/list/list.tsx @@ -2,7 +2,7 @@ import Sortable from "sortablejs"; import { debounce } from "lodash-es"; import { PropertyValues } from "lit"; -import { LitElement, property, createEvent, h, method, state, JsxNode } from "@arcgis/lumina"; +import { createEvent, h, JsxNode, LitElement, method, property, state } from "@arcgis/lumina"; import { getRootNode, slotChangeHasAssignedElement } from "../../utils/dom"; import { InteractiveComponent, @@ -10,14 +10,14 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { createObserver } from "../../utils/observers"; -import { SelectionMode, InteractionMode, Scale } from "../interfaces"; +import { InteractionMode, Scale, SelectionMode } from "../interfaces"; import { ItemData } from "../list-item/interfaces"; import { + expandedAncestors, isListItem, listItemGroupSelector, listItemSelector, listSelector, - expandedAncestors, updateListItemChildren, } from "../list-item/utils"; import { @@ -26,7 +26,6 @@ import { SortableComponent, } from "../../utils/sortableComponent"; import { SLOTS as STACK_SLOTS } from "../stack/resources"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem, numberStringFormatter } from "../../utils/locale"; import { MoveEventDetail, MoveTo, ReorderEventDetail } from "../sort-handle/interfaces"; import { guid } from "../../utils/guid"; @@ -36,10 +35,10 @@ import type { ListItem } from "../list-item/list-item"; import type { Filter } from "../filter/filter"; import type { ListItemGroup } from "../list-item-group/list-item-group"; import { DEBOUNCE } from "../../utils/resources"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, SelectionAppearance, SLOTS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; -import { ListElement } from "./interfaces"; -import { ListDragDetail, ListDisplayMode } from "./interfaces"; +import { ListDisplayMode, ListDragDetail, ListElement } from "./interfaces"; import { styles } from "./list.scss"; declare global { @@ -111,6 +110,8 @@ export class List extends LitElement implements InteractiveComponent, SortableCo */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -289,13 +290,11 @@ export class List extends LitElement implements InteractiveComponent, SortableCo */ @method() async setFocus(): Promise { - await componentFocusable(this); - - if (this.filterEnabled) { - return this.filterEl?.setFocus(); - } - - return this.focusableItems.find((listItem) => listItem.active)?.setFocus(); + return this.focusSetter(() => { + return this.filterEnabled + ? this.filterEl + : this.focusableItems.find((listItem) => listItem.active); + }); } //#endregion diff --git a/packages/calcite-components/src/components/menu-item/menu-item.tsx b/packages/calcite-components/src/components/menu-item/menu-item.tsx index c1d32db3d25..23624ac52d9 100644 --- a/packages/calcite-components/src/components/menu-item/menu-item.tsx +++ b/packages/calcite-components/src/components/menu-item/menu-item.tsx @@ -12,11 +12,11 @@ import { } from "@arcgis/lumina"; import { FlipContext, Layout } from "../interfaces"; import { Direction, getElementDir, slotChangeGetAssignedElements } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; import { CSS_UTILITY } from "../../utils/resources"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Action } from "../action/action"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, SLOTS, ICONS } from "./resources"; import { MenuItemCustomEvent } from "./interfaces"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -51,6 +51,8 @@ export class MenuItem extends LitElement { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -127,8 +129,9 @@ export class MenuItem extends LitElement { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.anchorEl.value.focus(); + return this.focusSetter(() => { + return this.anchorEl.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/menu/menu.tsx b/packages/calcite-components/src/components/menu/menu.tsx index b49cf2edc50..fc75bf69c6c 100644 --- a/packages/calcite-components/src/components/menu/menu.tsx +++ b/packages/calcite-components/src/components/menu/menu.tsx @@ -2,15 +2,10 @@ import { PropertyValues } from "lit"; import { LitElement, property, h, method, JsxNode, LuminaJsx } from "@arcgis/lumina"; import { useWatchAttributes } from "@arcgis/lumina/controllers"; -import { - focusElement, - focusElementInGroup, - focusFirstTabbable, - slotChangeGetAssignedElements, -} from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; +import { focusElement, focusElementInGroup, slotChangeGetAssignedElements } from "../../utils/dom"; import { useT9n } from "../../controllers/useT9n"; import type { MenuItem } from "../menu-item/menu-item"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { styles } from "./menu.scss"; @@ -44,6 +39,8 @@ export class Menu extends LitElement { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region Public Properties @@ -68,8 +65,9 @@ export class Menu extends LitElement { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.menuItems[0]); + return this.focusSetter(() => { + return this.menuItems[0]; + }); } //#endregion @@ -108,7 +106,7 @@ export class Menu extends LitElement { if (key === "ArrowDown") { if (target.layout === "vertical") { - focusElementInGroup(this.menuItems, target, "next", false); + focusElementInGroup(this.menuItems, target, "next", false, false); } else { if (event.detail.isSubmenuOpen) { submenuItems[0].setFocus(); @@ -116,7 +114,7 @@ export class Menu extends LitElement { } } else if (key === "ArrowUp") { if (this.layout === "vertical") { - focusElementInGroup(this.menuItems, target, "previous", false); + focusElementInGroup(this.menuItems, target, "previous", false, false); } else { if (event.detail.isSubmenuOpen) { submenuItems[submenuItems.length - 1].setFocus(); @@ -124,7 +122,7 @@ export class Menu extends LitElement { } } else if (key === "ArrowRight") { if (this.layout === "horizontal") { - focusElementInGroup(this.menuItems, target, "next", false); + focusElementInGroup(this.menuItems, target, "next", false, false); } else { if (event.detail.isSubmenuOpen) { submenuItems[0].setFocus(); @@ -132,7 +130,7 @@ export class Menu extends LitElement { } } else if (key === "ArrowLeft") { if (this.layout === "horizontal") { - focusElementInGroup(this.menuItems, target, "previous", false); + focusElementInGroup(this.menuItems, target, "previous", false, false); } else { if (event.detail.isSubmenuOpen) { this.focusParentElement(event.target as MenuItem["el"]); diff --git a/packages/calcite-components/src/components/modal/modal.tsx b/packages/calcite-components/src/components/modal/modal.tsx index 78ae7dd93fe..d97a5539749 100644 --- a/packages/calcite-components/src/components/modal/modal.tsx +++ b/packages/calcite-components/src/components/modal/modal.tsx @@ -13,11 +13,9 @@ import { } from "@arcgis/lumina"; import { ensureId, - focusFirstTabbable, slotChangeGetAssignedElements, slotChangeHasAssignedElement, } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; import { Kind, Scale } from "../interfaces"; @@ -26,6 +24,7 @@ import { logger } from "../../utils/logger"; import { useT9n } from "../../controllers/useT9n"; import { usePreventDocumentScroll } from "../../controllers/usePreventDocumentScroll"; import { FocusTrapOptions, useFocusTrap } from "../../controllers/useFocusTrap"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, ICONS, SLOTS } from "./resources"; import { styles } from "./modal.scss"; @@ -106,6 +105,8 @@ export class Modal extends LitElement implements OpenCloseComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + private keyDownHandler = (event: KeyboardEvent): void => { const { defaultPrevented, key } = event; @@ -253,8 +254,9 @@ export class Modal extends LitElement implements OpenCloseComponent { /** Sets focus on the component's "close" button (the first focusable item). */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } /** diff --git a/packages/calcite-components/src/components/navigation-logo/navigation-logo.tsx b/packages/calcite-components/src/components/navigation-logo/navigation-logo.tsx index 9a5dca62d4e..2200c2489e1 100644 --- a/packages/calcite-components/src/components/navigation-logo/navigation-logo.tsx +++ b/packages/calcite-components/src/components/navigation-logo/navigation-logo.tsx @@ -1,8 +1,8 @@ // @ts-strict-ignore import { h, Fragment, JsxNode, LitElement, method, property } from "@arcgis/lumina"; -import { componentFocusable } from "../../utils/component"; import { Heading, HeadingLevel } from "../functional/Heading"; import { IconNameOrString } from "../icon/interfaces"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS } from "./resources"; import { styles } from "./navigation-logo.scss"; @@ -21,6 +21,12 @@ export class NavigationLogo extends LitElement { // #endregion + // #region Private Properties + + private focusSetter = useSetFocus()(this); + + // #endregion + // #region Public Properties /** When `true`, the component is highlighted. */ @@ -71,10 +77,11 @@ export class NavigationLogo extends LitElement { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - if (this.href) { - this.el.focus(); - } + return this.focusSetter(() => { + if (this.href) { + return this.el; + } + }); } // #endregion diff --git a/packages/calcite-components/src/components/navigation-user/navigation-user.tsx b/packages/calcite-components/src/components/navigation-user/navigation-user.tsx index dfb45076938..8fa9c559773 100644 --- a/packages/calcite-components/src/components/navigation-user/navigation-user.tsx +++ b/packages/calcite-components/src/components/navigation-user/navigation-user.tsx @@ -1,6 +1,6 @@ // @ts-strict-ignore import { LitElement, property, h, method, JsxNode } from "@arcgis/lumina"; -import { componentFocusable } from "../../utils/component"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS } from "./resources"; import { styles } from "./navigation-user.scss"; @@ -19,6 +19,12 @@ export class NavigationUser extends LitElement { // #endregion + // #region Private Properties + + private focusSetter = useSetFocus()(this); + + // #endregion + // #region Public Properties /** When `true`, the component is highlighted. */ @@ -49,8 +55,9 @@ export class NavigationUser extends LitElement { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.el.focus(); + return this.focusSetter(() => { + return this.el; + }); } // #endregion diff --git a/packages/calcite-components/src/components/navigation/navigation.tsx b/packages/calcite-components/src/components/navigation/navigation.tsx index 9c26ed08997..252436ba9be 100644 --- a/packages/calcite-components/src/components/navigation/navigation.tsx +++ b/packages/calcite-components/src/components/navigation/navigation.tsx @@ -11,8 +11,8 @@ import { JsxNode, } from "@arcgis/lumina"; import { slotChangeHasAssignedElement } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; import type { Action } from "../action/action"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS, SLOTS } from "./resources"; import { styles } from "./navigation.scss"; @@ -44,6 +44,8 @@ export class Navigation extends LitElement { private navigationActionEl = createRef(); + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -83,8 +85,9 @@ export class Navigation extends LitElement { /** When `navigationAction` is `true`, sets focus on the component's action element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - return this.navigationActionEl.value?.setFocus(); + return this.focusSetter(() => { + return this.navigationActionEl.value; + }); } // #endregion diff --git a/packages/calcite-components/src/components/notice/notice.tsx b/packages/calcite-components/src/components/notice/notice.tsx index 6f80402c268..b21da30af47 100644 --- a/packages/calcite-components/src/components/notice/notice.tsx +++ b/packages/calcite-components/src/components/notice/notice.tsx @@ -12,13 +12,13 @@ import { stringOrBoolean, } from "@arcgis/lumina"; import { setRequestedIcon, slotChangeHasAssignedElement } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; import { Kind, Scale, Width } from "../interfaces"; import { KindIcons } from "../resources"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; import { getIconScale } from "../../utils/component"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, SLOTS } from "./resources"; import { styles } from "./notice.scss"; @@ -66,6 +66,8 @@ export class Notice extends LitElement implements OpenCloseComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -110,18 +112,10 @@ export class Notice extends LitElement implements OpenCloseComponent { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - const noticeLinkEl = this.el.querySelector("calcite-link"); - - if (!this.closeButton.value && !noticeLinkEl) { - return; - } - if (noticeLinkEl) { - return noticeLinkEl.setFocus(); - } else if (this.closeButton.value) { - this.closeButton.value.focus(); - } + return this.focusSetter(() => { + const noticeLinkEl = this.el.querySelector("calcite-link"); + return noticeLinkEl || this.closeButton.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/pagination/pagination.tsx b/packages/calcite-components/src/components/pagination/pagination.tsx index 0c07f015ed1..9d04c6b47b5 100644 --- a/packages/calcite-components/src/components/pagination/pagination.tsx +++ b/packages/calcite-components/src/components/pagination/pagination.tsx @@ -1,13 +1,13 @@ // @ts-strict-ignore import { PropertyValues } from "lit"; import { LitElement, property, createEvent, h, method, state, JsxNode } from "@arcgis/lumina"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem, numberStringFormatter } from "../../utils/locale"; import { Scale } from "../interfaces"; import { createObserver } from "../../utils/observers"; import { breakpoints } from "../../utils/responsive"; import { getIconScale } from "../../utils/component"; import { useT9n } from "../../controllers/useT9n"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; import { styles } from "./pagination.scss"; @@ -60,6 +60,8 @@ export class Pagination extends LitElement { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -142,8 +144,9 @@ export class Pagination extends LitElement { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.el.focus(); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/panel/panel.tsx b/packages/calcite-components/src/components/panel/panel.tsx index e3a198fa731..04e96334adb 100644 --- a/packages/calcite-components/src/components/panel/panel.tsx +++ b/packages/calcite-components/src/components/panel/panel.tsx @@ -1,16 +1,12 @@ // @ts-strict-ignore import { LitElement, property, createEvent, h, method, state, JsxNode } from "@arcgis/lumina"; -import { - focusFirstTabbable, - slotChangeGetAssignedElements, - slotChangeHasAssignedElement, -} from "../../utils/dom"; +import { slotChangeGetAssignedElements, slotChangeHasAssignedElement } from "../../utils/dom"; import { InteractiveComponent, InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable, getIconScale } from "../../utils/component"; +import { getIconScale } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { SLOTS as ACTION_MENU_SLOTS } from "../action-menu/resources"; import { Heading, HeadingLevel } from "../functional/Heading"; @@ -24,6 +20,7 @@ import { CollapseDirection, Scale } from "../interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Alert } from "../alert/alert"; import type { ActionBar } from "../action-bar/action-bar"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { IconNameOrString } from "../icon/interfaces"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, ICONS, IDS, SLOTS } from "./resources"; @@ -75,6 +72,8 @@ export class Panel extends LitElement implements InteractiveComponent { private _closed = false; + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -209,8 +208,9 @@ export class Panel extends LitElement implements InteractiveComponent { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.containerEl); + return this.focusSetter(() => { + return this.containerEl; + }); } //#endregion diff --git a/packages/calcite-components/src/components/popover/popover.tsx b/packages/calcite-components/src/components/popover/popover.tsx index 4502449aa4a..0d501e05a27 100644 --- a/packages/calcite-components/src/components/popover/popover.tsx +++ b/packages/calcite-components/src/components/popover/popover.tsx @@ -26,18 +26,18 @@ import { ReferenceElement, reposition, } from "../../utils/floating-ui"; -import { focusFirstTabbable, queryElementRoots, toAriaBoolean } from "../../utils/dom"; +import { queryElementRoots, toAriaBoolean } from "../../utils/dom"; import { guid } from "../../utils/guid"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; import { Heading, HeadingLevel } from "../functional/Heading"; import { Scale } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { FloatingArrow } from "../functional/FloatingArrow"; import { getIconScale } from "../../utils/component"; import { useT9n } from "../../controllers/useT9n"; import type { Action } from "../action/action"; import { FocusTrapOptions, useFocusTrap } from "../../controllers/useFocusTrap"; +import { useSetFocus } from "../../controllers/useSetFocus"; import PopoverManager from "./PopoverManager"; import T9nStrings from "./assets/t9n/messages.en.json"; import { ARIA_CONTROLS, ARIA_EXPANDED, CSS, defaultPopoverPlacement } from "./resources"; @@ -103,6 +103,8 @@ export class Popover extends LitElement implements FloatingUIComponent, OpenClos */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -251,9 +253,9 @@ export class Popover extends LitElement implements FloatingUIComponent, OpenClos /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.requestUpdate(); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } /** Updates the element(s) that are used within the focus-trap of the component. */ diff --git a/packages/calcite-components/src/components/radio-button-group/radio-button-group.tsx b/packages/calcite-components/src/components/radio-button-group/radio-button-group.tsx index cd9ad30358b..451cace1f90 100644 --- a/packages/calcite-components/src/components/radio-button-group/radio-button-group.tsx +++ b/packages/calcite-components/src/components/radio-button-group/radio-button-group.tsx @@ -13,11 +13,10 @@ import { } from "@arcgis/lumina"; import { createObserver } from "../../utils/observers"; import { Layout, Scale, Status } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import { Validation } from "../functional/Validation"; import { IconNameOrString } from "../icon/interfaces"; import type { RadioButton } from "../radio-button/radio-button"; -import { focusFirstTabbable } from "../../utils/dom"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, IDS } from "./resources"; import { styles } from "./radio-button-group.scss"; @@ -39,6 +38,8 @@ export class RadioButtonGroup extends LitElement { private mutationObserver = createObserver("mutation", () => this.passPropsToRadioButtons()); + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -97,14 +98,13 @@ export class RadioButtonGroup extends LitElement { /** Sets focus on the fist focusable `calcite-radio-button` element in the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); + return this.focusSetter(() => { + if (this.selectedItem && !this.selectedItem.disabled) { + return this.selectedItem; + } - if (this.selectedItem && !this.selectedItem.disabled) { - focusFirstTabbable(this.selectedItem); - } - if (this.radioButtons.length > 0) { - focusFirstTabbable(this.getFocusableRadioButton()); - } + return this.getFocusableRadioButton(); + }); } // #endregion @@ -153,6 +153,7 @@ export class RadioButtonGroup extends LitElement { // #endregion // #region Private Methods + private passPropsToRadioButtons(): void { this.radioButtons = Array.from(this.el.querySelectorAll("calcite-radio-button")); this.selectedItem = diff --git a/packages/calcite-components/src/components/radio-button/radio-button.tsx b/packages/calcite-components/src/components/radio-button/radio-button.tsx index d0453920c90..825cf0f070a 100644 --- a/packages/calcite-components/src/components/radio-button/radio-button.tsx +++ b/packages/calcite-components/src/components/radio-button/radio-button.tsx @@ -2,7 +2,7 @@ import { PropertyValues } from "lit"; import { LitElement, property, createEvent, h, method, JsxNode } from "@arcgis/lumina"; import { getRoundRobinIndex } from "../../utils/array"; -import { focusElement, getElementDir } from "../../utils/dom"; +import { getElementDir } from "../../utils/dom"; import { CheckableFormComponent, connectForm, @@ -15,9 +15,9 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { Scale } from "../interfaces"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS } from "./resources"; import { styles } from "./radio-button.scss"; @@ -51,6 +51,8 @@ export class RadioButton private rootNode: HTMLElement; + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -125,11 +127,9 @@ export class RadioButton /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - if (!this.disabled) { - focusElement(this.containerEl); - } + return this.focusSetter(() => { + return this.containerEl; + }); } // #endregion diff --git a/packages/calcite-components/src/components/rating/rating.tsx b/packages/calcite-components/src/components/rating/rating.tsx index 1f5422021b1..fabe32f5524 100644 --- a/packages/calcite-components/src/components/rating/rating.tsx +++ b/packages/calcite-components/src/components/rating/rating.tsx @@ -23,13 +23,12 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { Scale, Status } from "../interfaces"; -import { focusFirstTabbable } from "../../utils/dom"; import { Validation } from "../functional/Validation"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { StarIcon } from "./functional/star"; import { Star } from "./interfaces"; @@ -81,6 +80,8 @@ export class Rating */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -187,8 +188,9 @@ export class Rating /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } //#endregion diff --git a/packages/calcite-components/src/components/segmented-control/segmented-control.tsx b/packages/calcite-components/src/components/segmented-control/segmented-control.tsx index f96bed7323c..dafe4ba616e 100644 --- a/packages/calcite-components/src/components/segmented-control/segmented-control.tsx +++ b/packages/calcite-components/src/components/segmented-control/segmented-control.tsx @@ -25,12 +25,12 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { Appearance, Layout, Scale, Status, Width } from "../interfaces"; import { Validation } from "../functional/Validation"; import { IconNameOrString } from "../icon/interfaces"; import type { SegmentedControlItem } from "../segmented-control-item/segmented-control-item"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, IDS } from "./resources"; import { styles } from "./segmented-control.scss"; @@ -61,6 +61,8 @@ export class SegmentedControl labelEl: Label["el"]; + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -151,9 +153,9 @@ export class SegmentedControl /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - (this.selectedItem || this.items[0])?.focus(); + return this.focusSetter(() => { + return this.selectedItem || this.items[0]; + }); } // #endregion @@ -217,6 +219,7 @@ export class SegmentedControl // #endregion // #region Private Methods + private valueHandler(value: string): void { const { items } = this; items.forEach((item) => (item.checked = item.value === value)); diff --git a/packages/calcite-components/src/components/select/select.tsx b/packages/calcite-components/src/components/select/select.tsx index efa8c915233..7269429fc50 100644 --- a/packages/calcite-components/src/components/select/select.tsx +++ b/packages/calcite-components/src/components/select/select.tsx @@ -9,7 +9,6 @@ import { JsxNode, stringOrBoolean, } from "@arcgis/lumina"; -import { focusElement } from "../../utils/dom"; import { afterConnectDefaultValueSet, connectForm, @@ -24,7 +23,6 @@ import { updateHostInteraction, } from "../../utils/interactive"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { createObserver } from "../../utils/observers"; import { Scale, Status, Width } from "../interfaces"; import { getIconScale } from "../../utils/component"; @@ -33,6 +31,7 @@ import { IconNameOrString } from "../icon/interfaces"; import type { Option } from "../option/option"; import type { OptionGroup } from "../option-group/option-group"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { styles } from "./select.scss"; import { CSS, IDS } from "./resources"; @@ -78,6 +77,8 @@ export class Select private selectEl: HTMLSelectElement; + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -166,9 +167,9 @@ export class Select /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - focusElement(this.selectEl); + return this.focusSetter(() => { + return this.selectEl; + }); } // #endregion @@ -239,6 +240,7 @@ export class Select // #endregion // #region Private Methods + private handleInternalSelectChange(): void { const selected = this.selectEl.selectedOptions[0]; this.selectFromNativeOption(selected); diff --git a/packages/calcite-components/src/components/sheet/sheet.tsx b/packages/calcite-components/src/components/sheet/sheet.tsx index 29ef90b538e..926c6b11d4b 100644 --- a/packages/calcite-components/src/components/sheet/sheet.tsx +++ b/packages/calcite-components/src/components/sheet/sheet.tsx @@ -12,8 +12,7 @@ import { property, setAttribute, } from "@arcgis/lumina"; -import { ensureId, focusFirstTabbable, getElementDir, getStylePixelValue } from "../../utils/dom"; -import { componentFocusable } from "../../utils/component"; +import { ensureId, getElementDir, getStylePixelValue } from "../../utils/dom"; import { createObserver } from "../../utils/observers"; import { toggleOpenClose, OpenCloseComponent } from "../../utils/openCloseComponent"; import { getDimensionClass } from "../../utils/dynamicClasses"; @@ -24,6 +23,7 @@ import { useT9n } from "../../controllers/useT9n"; import { usePreventDocumentScroll } from "../../controllers/usePreventDocumentScroll"; import { FocusTrapOptions, useFocusTrap } from "../../controllers/useFocusTrap"; import { resizeStep, resizeShiftStep } from "../../utils/resources"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS, IDS } from "./resources"; import { DisplayMode, ResizeValues } from "./interfaces"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -87,6 +87,8 @@ export class Sheet extends LitElement implements OpenCloseComponent { transitionEl: HTMLDivElement; + private focusSetter = useSetFocus()(this); + private keyDownHandler = (event: KeyboardEvent): void => { const { defaultPrevented, key } = event; @@ -228,8 +230,9 @@ export class Sheet extends LitElement implements OpenCloseComponent { /** Sets focus on the component's "close" button - the first focusable item. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } /** diff --git a/packages/calcite-components/src/components/slider/slider.tsx b/packages/calcite-components/src/components/slider/slider.tsx index 53d8425e67d..cf14a0f7add 100644 --- a/packages/calcite-components/src/components/slider/slider.tsx +++ b/packages/calcite-components/src/components/slider/slider.tsx @@ -29,7 +29,6 @@ import { } from "../../utils/interactive"; import { isActivationKey } from "../../utils/key"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { NumberingSystem, numberStringFormatter } from "../../utils/locale"; import { clamp, decimalPlaces } from "../../utils/math"; import { ColorStop, DataSeries } from "../graph/interfaces"; @@ -38,6 +37,7 @@ import { BigDecimal } from "../../utils/number"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, IDS, maxTickElementThreshold } from "./resources"; import { ActiveSliderProperty, SetValueProperty, SideOffset, ThumbType } from "./interfaces"; import { styles } from "./slider.scss"; @@ -161,6 +161,8 @@ export class Slider private trackEl: HTMLDivElement; + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -325,10 +327,9 @@ export class Slider /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - const handle = this.minHandle ? this.minHandle : this.maxHandle; - handle?.focus(); + return this.focusSetter(() => { + return this.minHandle || this.maxHandle; + }); } // #endregion @@ -422,6 +423,7 @@ export class Slider // #endregion // #region Private Methods + private handleKeyDown(event: KeyboardEvent): void { const mirror = this.shouldMirror(); const { activeProp, max, min, pageStep, step } = this; diff --git a/packages/calcite-components/src/components/sort-handle/sort-handle.tsx b/packages/calcite-components/src/components/sort-handle/sort-handle.tsx index e5b4c0271fb..00392c4c3a8 100644 --- a/packages/calcite-components/src/components/sort-handle/sort-handle.tsx +++ b/packages/calcite-components/src/components/sort-handle/sort-handle.tsx @@ -1,7 +1,6 @@ // @ts-strict-ignore import { PropertyValues } from "lit"; import { LitElement, property, createEvent, h, method, JsxNode, state } from "@arcgis/lumina"; -import { componentFocusable } from "../../utils/component"; import { InteractiveComponent, InteractiveContainer, @@ -16,6 +15,7 @@ import { } from "../../utils/floating-ui"; import { useT9n } from "../../controllers/useT9n"; import type { Dropdown } from "../dropdown/dropdown"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, ICONS, IDS, REORDER_VALUES, SLOTS, SUBSTITUTIONS } from "./resources"; import { MoveEventDetail, MoveTo, Reorder, ReorderEventDetail } from "./interfaces"; @@ -38,6 +38,8 @@ export class SortHandle extends LitElement implements InteractiveComponent { private dropdownEl: Dropdown["el"]; + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -118,8 +120,9 @@ export class SortHandle extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.dropdownEl?.setFocus(); + return this.focusSetter(() => { + return this.dropdownEl; + }); } // #endregion diff --git a/packages/calcite-components/src/components/split-button/split-button.tsx b/packages/calcite-components/src/components/split-button/split-button.tsx index 688faab3a7b..ac4515e28d4 100644 --- a/packages/calcite-components/src/components/split-button/split-button.tsx +++ b/packages/calcite-components/src/components/split-button/split-button.tsx @@ -14,11 +14,10 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable } from "../../utils/component"; import { DropdownIconType } from "../button/interfaces"; import { Appearance, FlipContext, Kind, Scale, Width } from "../interfaces"; import { IconNameOrString } from "../icon/interfaces"; -import { focusFirstTabbable } from "../../utils/dom"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS, SLOTS } from "./resources"; import { styles } from "./split-button.scss"; @@ -50,6 +49,8 @@ export class SplitButton extends LitElement implements InteractiveComponent { : ICONS.handleVertical; } + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -155,8 +156,9 @@ export class SplitButton extends LitElement implements InteractiveComponent { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - focusFirstTabbable(this.el); + return this.focusSetter(() => { + return this.el; + }); } // #endregion @@ -180,6 +182,7 @@ export class SplitButton extends LitElement implements InteractiveComponent { // #endregion // #region Private Methods + private calciteSplitButtonPrimaryClickHandler(): void { this.calciteSplitButtonPrimaryClick.emit(); } diff --git a/packages/calcite-components/src/components/stepper-item/stepper-item.tsx b/packages/calcite-components/src/components/stepper-item/stepper-item.tsx index 10dc8bad71a..f2145ab61c0 100644 --- a/packages/calcite-components/src/components/stepper-item/stepper-item.tsx +++ b/packages/calcite-components/src/components/stepper-item/stepper-item.tsx @@ -24,11 +24,11 @@ import { StepperLayout, } from "../stepper/interfaces"; import { NumberingSystem, numberStringFormatter } from "../../utils/locale"; -import { componentFocusable } from "../../utils/component"; import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import type { Stepper } from "../stepper/stepper"; import { isHidden } from "../../utils/component"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { slotChangeHasContent } from "../../utils/dom"; import { CSS, ICONS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -68,6 +68,8 @@ export class StepperItem extends LitElement implements InteractiveComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -147,9 +149,9 @@ export class StepperItem extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - (this.layout === "vertical" ? this.el : this.headerEl.value)?.focus(); + return this.focusSetter(() => { + return this.layout === "vertical" ? this.el : this.headerEl.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/switch/switch.tsx b/packages/calcite-components/src/components/switch/switch.tsx index 6976181c7ba..fe014cd2ca9 100644 --- a/packages/calcite-components/src/components/switch/switch.tsx +++ b/packages/calcite-components/src/components/switch/switch.tsx @@ -1,6 +1,5 @@ // @ts-strict-ignore import { LitElement, property, createEvent, h, method, JsxNode } from "@arcgis/lumina"; -import { focusElement } from "../../utils/dom"; import { CheckableFormComponent, connectForm, @@ -14,9 +13,9 @@ import { } from "../../utils/interactive"; import { isActivationKey } from "../../utils/key"; import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from "../../utils/label"; -import { componentFocusable } from "../../utils/component"; import { Scale } from "../interfaces"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS } from "./resources"; import { styles } from "./switch.scss"; @@ -48,6 +47,8 @@ export class Switch private switchEl: HTMLDivElement; + private focusSetter = useSetFocus()(this); + // #endregion // #region Public Properties @@ -88,9 +89,9 @@ export class Switch /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - focusElement(this.switchEl); + return this.focusSetter(() => { + return this.switchEl; + }); } // #endregion diff --git a/packages/calcite-components/src/components/table-cell/table-cell.tsx b/packages/calcite-components/src/components/table-cell/table-cell.tsx index dd97e404d17..e14dad91133 100644 --- a/packages/calcite-components/src/components/table-cell/table-cell.tsx +++ b/packages/calcite-components/src/components/table-cell/table-cell.tsx @@ -3,7 +3,6 @@ import { PropertyValues } from "lit"; import { createRef } from "lit-html/directives/ref.js"; import { LitElement, property, h, method, state, JsxNode } from "@arcgis/lumina"; import { Alignment, Scale } from "../interfaces"; -import { componentFocusable } from "../../utils/component"; import { InteractiveComponent, InteractiveContainer, @@ -13,6 +12,7 @@ import { RowType, TableInteractionMode } from "../table/interfaces"; import { getElementDir } from "../../utils/dom"; import { CSS_UTILITY } from "../../utils/resources"; import { useT9n } from "../../controllers/useT9n"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; import { styles } from "./table-cell.scss"; @@ -42,6 +42,8 @@ export class TableCell extends LitElement implements InteractiveComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -111,8 +113,9 @@ export class TableCell extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.containerEl.value.focus(); + return this.focusSetter(() => { + return this.containerEl.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/table-header/table-header.tsx b/packages/calcite-components/src/components/table-header/table-header.tsx index f387cae4927..23d0e8f3656 100644 --- a/packages/calcite-components/src/components/table-header/table-header.tsx +++ b/packages/calcite-components/src/components/table-header/table-header.tsx @@ -2,11 +2,11 @@ import { PropertyValues } from "lit"; import { createRef } from "lit-html/directives/ref.js"; import { LitElement, property, h, method, state, JsxNode } from "@arcgis/lumina"; -import { componentFocusable } from "../../utils/component"; import { Alignment, Scale, SelectionMode } from "../interfaces"; import { RowType, TableInteractionMode } from "../table/interfaces"; import { getIconScale } from "../../utils/component"; import { useT9n } from "../../controllers/useT9n"; +import { useSetFocus } from "../../controllers/useSetFocus"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, ICONS } from "./resources"; import { styles } from "./table-header.scss"; @@ -35,6 +35,8 @@ export class TableHeader extends LitElement { */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -111,8 +113,9 @@ export class TableHeader extends LitElement { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.containerEl.value.focus(); + return this.focusSetter(() => { + return this.containerEl.value; + }); } //#endregion diff --git a/packages/calcite-components/src/components/table-row/table-row.tsx b/packages/calcite-components/src/components/table-row/table-row.tsx index 60bd650f126..c4e4296388c 100644 --- a/packages/calcite-components/src/components/table-row/table-row.tsx +++ b/packages/calcite-components/src/components/table-row/table-row.tsx @@ -274,11 +274,11 @@ export class TableRow extends LitElement implements InteractiveComponent { event.preventDefault(); break; case "ArrowLeft": - focusElementInGroup(cells, el, "previous", false); + focusElementInGroup(cells, el, "previous", false, false); event.preventDefault(); break; case "ArrowRight": - focusElementInGroup(cells, el, "next", false); + focusElementInGroup(cells, el, "next", false, false); event.preventDefault(); break; case "Home": @@ -286,7 +286,7 @@ export class TableRow extends LitElement implements InteractiveComponent { this.emitTableRowFocusRequest(1, this.positionAll, "first"); event.preventDefault(); } else { - focusElementInGroup(cells, el, "first", false); + focusElementInGroup(cells, el, "first", false, false); event.preventDefault(); } break; @@ -295,7 +295,7 @@ export class TableRow extends LitElement implements InteractiveComponent { this.emitTableRowFocusRequest(this.rowCells?.length, this.positionAll, "last", true); event.preventDefault(); } else { - focusElementInGroup(cells, el, "last", false); + focusElementInGroup(cells, el, "last", false, false); event.preventDefault(); } break; diff --git a/packages/calcite-components/src/components/text-area/text-area.tsx b/packages/calcite-components/src/components/text-area/text-area.tsx index 56cefddb65f..bc348196c8d 100644 --- a/packages/calcite-components/src/components/text-area/text-area.tsx +++ b/packages/calcite-components/src/components/text-area/text-area.tsx @@ -23,7 +23,6 @@ import { connectLabel, disconnectLabel, getLabelText, LabelableComponent } from import { slotChangeHasAssignedElement } from "../../utils/dom"; import { NumberingSystem, numberStringFormatter } from "../../utils/locale"; import { createObserver } from "../../utils/observers"; -import { componentFocusable } from "../../utils/component"; import { InteractiveComponent, InteractiveContainer, @@ -37,6 +36,7 @@ import { IconNameOrString } from "../icon/interfaces"; import { useT9n } from "../../controllers/useT9n"; import { useCancelable } from "../../controllers/useCancelable"; import type { Label } from "../label/label"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CharacterLengthObj } from "./interfaces"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, IDS, NO_DIMENSIONS, RESIZE_TIMEOUT, SLOTS } from "./resources"; @@ -140,6 +140,8 @@ export class TextArea */ messages = useT9n({ blocking: true }); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -303,8 +305,9 @@ export class TextArea /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - this.textAreaEl.focus(); + return this.focusSetter(() => { + return this.textAreaEl; + }); } //#endregion diff --git a/packages/calcite-components/src/components/tile-group/tile-group.tsx b/packages/calcite-components/src/components/tile-group/tile-group.tsx index 9c3efee2545..dc97738d6db 100644 --- a/packages/calcite-components/src/components/tile-group/tile-group.tsx +++ b/packages/calcite-components/src/components/tile-group/tile-group.tsx @@ -220,17 +220,17 @@ export class TileGroup switch (event.detail.key) { case "ArrowDown": case "ArrowRight": - focusElementInGroup(interactiveItems, event.detail.target, "next"); + focusElementInGroup(interactiveItems, event.detail.target, "next", true, false); break; case "ArrowUp": case "ArrowLeft": - focusElementInGroup(interactiveItems, event.detail.target, "previous"); + focusElementInGroup(interactiveItems, event.detail.target, "previous", true, false); break; case "Home": - focusElementInGroup(interactiveItems, event.detail.target, "first"); + focusElementInGroup(interactiveItems, event.detail.target, "first", true, false); break; case "End": - focusElementInGroup(interactiveItems, event.detail.target, "last"); + focusElementInGroup(interactiveItems, event.detail.target, "last", true, false); break; } } diff --git a/packages/calcite-components/src/components/tile-select/tile-select.tsx b/packages/calcite-components/src/components/tile-select/tile-select.tsx index 1aea3debbf9..13cdbbd8615 100644 --- a/packages/calcite-components/src/components/tile-select/tile-select.tsx +++ b/packages/calcite-components/src/components/tile-select/tile-select.tsx @@ -7,12 +7,12 @@ import { InteractiveContainer, updateHostInteraction, } from "../../utils/interactive"; -import { componentFocusable } from "../../utils/component"; import { Alignment, Width } from "../interfaces"; import { IconNameOrString } from "../icon/interfaces"; import { logger } from "../../utils/logger"; import type { RadioButton } from "../radio-button/radio-button"; import type { Checkbox } from "../checkbox/checkbox"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { TileSelectType } from "./interfaces"; import { CSS } from "./resources"; import { styles } from "./tile-select.scss"; @@ -40,6 +40,8 @@ export class TileSelect extends LitElement implements InteractiveComponent { private input: Checkbox["el"] | RadioButton["el"]; + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -100,9 +102,9 @@ export class TileSelect extends LitElement implements InteractiveComponent { /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - return this.input?.setFocus(); + return this.focusSetter(() => { + return this.input; + }); } // #endregion @@ -173,6 +175,7 @@ export class TileSelect extends LitElement implements InteractiveComponent { // #endregion // #region Private Methods + private checkboxChangeHandler(event: CustomEvent): void { const checkbox = event.target as Checkbox["el"]; if (checkbox === this.input) { diff --git a/packages/calcite-components/src/components/tile/tile.tsx b/packages/calcite-components/src/components/tile/tile.tsx index a2e934e9e03..9479e458ff5 100644 --- a/packages/calcite-components/src/components/tile/tile.tsx +++ b/packages/calcite-components/src/components/tile/tile.tsx @@ -9,7 +9,7 @@ import { Alignment, Layout, Scale, SelectionAppearance, SelectionMode } from ".. import { slotChangeHasAssignedElement } from "../../utils/dom"; import { SelectableComponent } from "../../utils/selectableComponent"; import { IconNameOrString } from "../icon/interfaces"; -import { componentFocusable } from "../../utils/component"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { CSS, ICONS, SLOTS } from "./resources"; import { styles } from "./tile.scss"; @@ -36,6 +36,8 @@ export class Tile extends LitElement implements InteractiveComponent, Selectable private containerEl: HTMLDivElement; + private focusSetter = useSetFocus()(this); + // #endregion // #region State Properties @@ -151,10 +153,11 @@ export class Tile extends LitElement implements InteractiveComponent, Selectable /** Sets focus on the component. */ @method() async setFocus(): Promise { - await componentFocusable(this); - if (!this.disabled && this.interactive) { - this.containerEl?.focus(); - } + return this.focusSetter(() => { + if (this.interactive) { + return this.containerEl; + } + }); } // #endregion diff --git a/packages/calcite-components/src/components/time-picker/time-picker.tsx b/packages/calcite-components/src/components/time-picker/time-picker.tsx index 98f06ad6373..434ca2035c4 100644 --- a/packages/calcite-components/src/components/time-picker/time-picker.tsx +++ b/packages/calcite-components/src/components/time-picker/time-picker.tsx @@ -10,6 +10,7 @@ import { componentFocusable } from "../../utils/component"; import { decimalPlaces } from "../../utils/math"; import { getElementDir } from "../../utils/dom"; import { useT9n } from "../../controllers/useT9n"; +import { useSetFocus } from "../../controllers/useSetFocus"; import { TimeComponent, useTime } from "../../controllers/useTime"; import { CSS, ICONS } from "./resources"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -53,6 +54,8 @@ export class TimePicker extends LitElement implements TimeComponent { */ messages = useT9n(); + private focusSetter = useSetFocus()(this); + //#endregion //#region State Properties @@ -105,9 +108,9 @@ export class TimePicker extends LitElement implements TimeComponent { /** Sets focus on the component's first focusable element. */ @method() async setFocus(): Promise { - await componentFocusable(this); - - this.el?.focus(); + return this.focusSetter(() => { + return this.el; + }); } //#endregion @@ -359,6 +362,8 @@ export class TimePicker extends LitElement implements TimeComponent { //#region Rendering + // #region Rendering + override render(): JsxNode { const { activeEl, messages, scale } = this; const { _lang: locale } = messages; diff --git a/packages/calcite-components/src/controllers/useSetFocus.browser.spec.tsx b/packages/calcite-components/src/controllers/useSetFocus.browser.spec.tsx new file mode 100644 index 00000000000..f09ac84ca30 --- /dev/null +++ b/packages/calcite-components/src/controllers/useSetFocus.browser.spec.tsx @@ -0,0 +1,276 @@ +import { mount } from "@arcgis/lumina-compiler/testing"; +import { createRef } from "lit/directives/ref.js"; +import { h, JsxNode, LitElement, ToElement } from "@arcgis/lumina"; +import { describe, expect, it, vi } from "vitest"; +import { Input } from "../components/input/input"; +import { useSetFocus } from "./useSetFocus"; + +describe("useSetFocus", () => { + it("focuses native elements", async () => { + class Test extends LitElement { + private focusSetter = useSetFocus()(this); + private inputRef = createRef(); + + async setFocus(): Promise { + return this.focusSetter(() => this.inputRef.value); + } + + override render(): JsxNode { + return ; + } + } + + const { el } = await mount(Test); + + expect(document.activeElement).not.toBe(el); + await el.setFocus(); + expect(document.activeElement).toBe(el); + }); + + it("focuses custom elements", async () => { + class Test extends LitElement { + private focusSetter = useSetFocus()(this); + private inputRef = createRef>(); + + async setFocus(): Promise { + return this.focusSetter(() => this.inputRef.value); + } + + override render(): JsxNode { + return ; + } + } + + const { el } = await mount(Test); + + expect(document.activeElement).not.toBe(el); + await el.setFocus(); + expect(document.activeElement).toBe(el); + }); + + it("focuses focusable host component", async () => { + class Test extends LitElement { + private focusSetter = useSetFocus()(this); + + async setFocus(): Promise { + return this.focusSetter(() => this.el); + } + + override render(): JsxNode { + return
; + } + } + + const { el } = await mount(Test); + + expect(document.activeElement).not.toBe(el); + await el.setFocus(); + expect(document.activeElement).toBe(el); + }); + + it("bails if component is disabled", async () => { + class Test extends LitElement { + private focusSetter = useSetFocus()(this); + + disabled = true; + + async setFocus(): Promise { + return this.focusSetter(() => this.el); + } + + override render(): JsxNode { + return
; + } + } + + const { el } = await mount(Test); + + expect(document.activeElement).not.toBe(el); + await el.setFocus(); + expect(document.activeElement).not.toBe(el); + + el.disabled = false; + await el.setFocus(); + expect(document.activeElement).toBe(el); + }); + + it("bails if component is blurred before setFocus resolves", async () => { + class Test extends LitElement { + focusSetter = useSetFocus()(this); + private inputRef = createRef>(); + + async setFocus(): Promise { + return this.focusSetter(() => this.inputRef.value); + } + + override render(): JsxNode { + return ; + } + } + + const { el } = await mount(Test); + const i = document.createElement("input"); + document.body.append(i); + + expect(document.activeElement).not.toBe(el); + + const input = el.shadowRoot.querySelector("calcite-input")!; + await input.setFocus(); + + const spy = vi.spyOn(input, "setFocus"); + const setFocusPromise = el.setFocus(); + i.focus(); + await setFocusPromise; + + expect(document.activeElement).toBe(i); + expect(spy).not.toHaveBeenCalled(); + }); + + it("bails if target focus element is not available", async () => { + class Test extends LitElement { + private focusSetter = useSetFocus()(this); + private inputRef = createRef>(); + private ready = false; + + async setFocus(): Promise { + return this.focusSetter(() => this.inputRef.value); + } + + override render(): JsxNode { + return this.ready ? : null; + } + } + + const { el } = await mount(Test); + + expect(document.activeElement).not.toBe(el); + await el.setFocus(); + expect(document.activeElement).not.toBe(el); + }); + + it("bails if component already has focus", async () => { + class Test extends LitElement { + focusSetter = useSetFocus()(this); + private inputRef = createRef>(); + + async setFocus(): Promise { + return this.focusSetter(() => this.inputRef.value); + } + + override render(): JsxNode { + return ; + } + } + + const { el } = await mount(Test); + expect(document.activeElement).not.toBe(el); + + const input = el.shadowRoot.querySelector("calcite-input")!; + await input.setFocus(); + + const spy = vi.spyOn(input, "setFocus"); + + expect(spy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(el); + + await el.setFocus(); + expect(spy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(el); + + await el.setFocus(); + expect(spy).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(el); + }); + + it("bails if focus moves from previously focused element to another before component's setFocus resolves", async () => { + class Test extends LitElement { + focusSetter = useSetFocus()(this); + + private inputRef = createRef>(); + + async setFocus(): Promise { + return this.focusSetter(() => this.inputRef.value); + } + + override render(): JsxNode { + return ; + } + } + + const { el } = await mount(Test); + const input = document.createElement("input"); + document.body.append(input); + + expect(document.activeElement).toBe(document.body); + + const internalInput = el.shadowRoot.querySelector("calcite-input")!; + const spy = vi.spyOn(internalInput, "setFocus"); + const setFocusPromise = el.setFocus(); + input.focus(); + await setFocusPromise; + + expect(document.activeElement).toBe(input); + expect(spy).not.toHaveBeenCalled(); + }); + + describe("focus behavior options", () => { + it("allows setting includeContainer", async () => { + class Test extends LitElement { + private focusSetter = useSetFocus()(this); + private ref = createRef(); + + async setFocus(): Promise { + return this.focusSetter(() => { + return { target: this.ref.value!, includeContainer: true }; + }); + } + + override render(): JsxNode { + return ( +
+ +
+ ); + } + } + const { el } = await mount(Test); + + expect(document.activeElement).not.toBe(el); + + await el.setFocus(); + expect(document.activeElement!.shadowRoot!.activeElement).toBe( + el.shadowRoot.querySelector("#target"), + ); + }); + + it("allows setting focus strategy", async () => { + class Test extends LitElement { + private focusSetter = useSetFocus()(this); + private ref = createRef(); + + async setFocus(): Promise { + return this.focusSetter(() => { + return { target: this.ref.value!, strategy: "focusable" }; + }); + } + + override render(): JsxNode { + return ( +
+
+ +
+ ); + } + } + const { el } = await mount(Test); + + expect(document.activeElement).not.toBe(el); + + await el.setFocus(); + expect(document.activeElement!.shadowRoot!.activeElement).toBe( + el.shadowRoot.querySelector("#target"), + ); + }); + }); +}); diff --git a/packages/calcite-components/src/controllers/useSetFocus.ts b/packages/calcite-components/src/controllers/useSetFocus.ts new file mode 100644 index 00000000000..f8b0f7a924b --- /dev/null +++ b/packages/calcite-components/src/controllers/useSetFocus.ts @@ -0,0 +1,84 @@ +import { makeGenericController } from "@arcgis/lumina/controllers"; +import { LitElement } from "@arcgis/lumina"; +import { componentFocusable } from "../utils/component"; +import { FocusableElement, focusElement, getRootNode } from "../utils/dom"; +import { type InteractiveComponent } from "../utils/interactive"; + +type FocusStrategy = "focusable" | "tabbable"; +type FocusConfig = { target: FocusableElement; includeContainer?: boolean; strategy?: FocusStrategy }; + +export interface UseSetFocus { + (getFocusTarget: () => FocusableElement | FocusConfig | undefined): Promise; +} + +interface SetFocusComponent extends LitElement, Partial> { + setFocus: () => Promise; +} + +/** + * A controller for centralized setFocus behavior. + * + * @param options + */ +export const useSetFocus = (): ReturnType< + typeof makeGenericController +> => { + return makeGenericController((component, controller) => { + let abortController: AbortController; + + function handleFocusOut(): void { + abortController?.abort(); + } + + controller.onLoad(() => { + component.listen("focus", () => { + abortController = new AbortController(); + component.el.addEventListener("focusout", handleFocusOut, { signal: abortController.signal }); + }); + }); + + controller.onDisconnected(() => { + component.el.removeEventListener("focusout", handleFocusOut); + }); + + return async (getFocusTarget): Promise => { + if (component.disabled) { + return; + } + + const focusConfig = toFocusConfig(getFocusTarget()); + if (!focusConfig) { + return; + } + + const { target, includeContainer, strategy } = focusConfig; + + const rootNode = getRootNode(component.el); + const currentActiveElement = rootNode.activeElement; + + await componentFocusable(component); + + const focusAlreadyChanged = currentActiveElement !== rootNode.activeElement; + + if (focusAlreadyChanged || (abortController && !abortController?.signal.aborted)) { + return; + } + + component.el.removeEventListener("focus", handleFocusOut); + + return focusElement(target, includeContainer, strategy, component.el); + }; + }); +}; + +function isFocusOverride(focusTarget: FocusableElement | FocusConfig): focusTarget is FocusConfig { + return "target" in focusTarget && ("includeContainer" in focusTarget || "strategy" in focusTarget); +} + +function toFocusConfig(focusTarget: FocusableElement | FocusConfig | undefined): FocusConfig | undefined { + if (!focusTarget) { + return; + } + + return isFocusOverride(focusTarget) ? focusTarget : { target: focusTarget }; +} diff --git a/packages/calcite-components/src/utils/dom.browser.spec.ts b/packages/calcite-components/src/utils/dom.browser.spec.ts index 30f8c43b79b..3435731de51 100644 --- a/packages/calcite-components/src/utils/dom.browser.spec.ts +++ b/packages/calcite-components/src/utils/dom.browser.spec.ts @@ -1,5 +1,5 @@ // @ts-strict-ignore -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { ModeName } from "../components/interfaces"; import { html } from "../../support/formatting"; import { waitForAnimationFrame } from "../tests/utils/timing"; @@ -7,6 +7,7 @@ import { createControlledPromise } from "../tests/utils/promises"; import { guidPattern } from "./guid.spec"; import { ensureId, + focusElement, focusElementInGroup, getModeName, getShadowRootNode, @@ -30,6 +31,31 @@ import { whenTransitionDone, } from "./dom"; +/** + * Registers a test element with a unique tag name. + * This is useful for testing custom elements without conflicts. + * + * @param elementClass + */ +function registerTestElement(elementClass: typeof HTMLElement): string { + // ensure unique tag name per test to avoid "custom element already defined" error + const tagName = + "test-element-" + + expect + .getState() + .currentTestName.split(">") + .map((part) => part.trim()) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with dashes + .replace(/^-+|-+$/g, "") // trim leading/trailing dashes + .replace(/--+/g, "-"); + + customElements.define(tagName, elementClass); + + return tagName; +} + describe("dom", () => { async function setUpSlotChange({ assignedElements = [], @@ -199,19 +225,6 @@ describe("dom", () => { describe("slot utils", () => { function defineTestElement(slotHandler: (slotEl: HTMLSlotElement) => void, slotHtml = ""): string { - // ensure unique tag name per test to avoid "custom element already defined" error - const tagName = - "test-element-" + - expect - .getState() - .currentTestName.split(">") - .map((part) => part.trim()) - .join(" ") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with dashes - .replace(/^-+|-+$/g, "") // trim leading/trailing dashes - .replace(/--+/g, "-"); - class TestElement extends HTMLElement { constructor() { super(); @@ -220,9 +233,8 @@ describe("dom", () => { shadow.querySelectorAll("slot").forEach(slotHandler); } } - customElements.define(tagName, TestElement); - return tagName; + return registerTestElement(TestElement); } function appendChildren(parent: HTMLElement, children: Node[]): void { @@ -523,17 +535,209 @@ describe("dom", () => { }); }); + describe("focusElement()", () => { + function create(tag: string, props?: Partial, appendTo: HTMLElement = document.body): HTMLElement { + const el = document.createElement(tag); + + if (props) { + Object.entries(props).forEach(([key, value]) => { + el[key] = value; + }); + } + + appendTo.append(el); + + return el; + } + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("focuses the element if it is focusable", () => { + const el = create("div", { tabIndex: 0 }); + focusElement(el); + expect(document.activeElement).toBe(el); + }); + + it("does not focus the element if it is not focusable", () => { + const el = create("div"); + focusElement(el); + expect(document.activeElement).not.toBe(el); + }); + + it("focuses first focusable child if includeContainer = false", () => { + const el = create("div", { tabIndex: -1 }); + const child = create("div", { tabIndex: 0 }, el); + focusElement(el, false); + expect(document.activeElement).toBe(child); + }); + + it("focuses element if focusable and includeContainer = true (default)", () => { + const el = create("div", { tabIndex: 0 }); + create("div", { tabIndex: 0 }, el); + focusElement(el, true); + expect(document.activeElement).toBe(el); + }); + + it("does not focus if element has no focusable child and includeContainer = false", () => { + const el = create("div"); + focusElement(el, false); + expect(document.activeElement).not.toBe(el); + }); + + it("focuses first focusable when strategy='focusable'", () => { + const el = create("div"); + const child = create("div", { tabIndex: -1 }, el); + focusElement(el, false, "focusable"); + expect(document.activeElement).toBe(child); + }); + + it("focuses first tabbable when strategy='tabbable'", () => { + const el = create("div", { tabIndex: -1 }); + const child = create("div", { tabIndex: 0 }, el); + focusElement(el, true, "tabbable"); + expect(document.activeElement).toBe(child); + }); + + it("avoids infinite loop on setFocus components by using context", async () => { + let useContext = true; + let setFocusCalls = 0; + + class Test extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = `
`; + } + + async setFocus(): Promise { + if (setFocusCalls++ > 10) { + // simulates infinite loop without having to trigger a real one in test environment + throw new RangeError("setFocus called too many times, likely an infinite loop"); + } + + return focusElement(this, false, "tabbable", useContext ? this : undefined); + } + } + + const testElTag = registerTestElement(Test); + + const el = document.createElement(testElTag) as Test; + document.body.appendChild(el); + vi.spyOn(el, "focus"); + vi.spyOn(el, "setFocus"); + + await el.setFocus(); + + expect(el.setFocus).toHaveBeenCalledTimes(1); + expect(el.focus).toHaveBeenCalledTimes(0); + + useContext = false; + try { + await el.setFocus(); + expect.unreachable("should not reach here, setFocus should throw an error"); + } catch (error) { + expect(error).toBeInstanceOf(RangeError); + } + }); + }); + describe("focusElementInGroup()", () => { - it("should cycle through the array by default", () => { - const elements = [document.createElement("div"), document.createElement("div"), document.createElement("div")]; + function createElements(withFocusableChild = false): HTMLElement[] { + const totalItems = 3; + + return Array.from({ length: totalItems }, (_, index) => { + const el = document.createElement("div"); + el.id = `item-${index}`; + el.tabIndex = 0; + + if (withFocusableChild) { + const child = document.createElement("div"); + child.id = `child-${index}`; + child.tabIndex = 0; + el.append(child); + } + + return el; + }); + } + + it("cycles through the array by default", () => { + const elements = createElements(); + document.body.append(...elements); + expect(focusElementInGroup(elements, elements[0], "previous")).toBe(elements[2]); + expect(document.activeElement).toBe(elements[2]); expect(focusElementInGroup(elements, elements[2], "next")).toBe(elements[0]); + expect(document.activeElement).toBe(elements[0]); }); - it("should not cycle through the array", () => { - const elements = [document.createElement("div"), document.createElement("div"), document.createElement("div")]; + it("supports not cycling through the array", () => { + const elements = createElements(); + document.body.append(...elements); + expect(focusElementInGroup(elements, elements[0], "previous", false)).toBe(elements[0]); + expect(document.activeElement).toBe(elements[0]); expect(focusElementInGroup(elements, elements[2], "next", false)).toBe(elements[2]); + expect(document.activeElement).toBe(elements[2]); + }); + + describe("when item and first child are both focusable", () => { + it("focus item (default)", () => { + const elements = createElements(true); + document.body.append(...elements); + + expect(focusElementInGroup(elements, elements[0], "previous")).toBe(elements[2]); + expect(document.activeElement).toBe(elements[2]); + expect(focusElementInGroup(elements, elements[2], "next")).toBe(elements[0]); + expect(document.activeElement).toBe(elements[0]); + }); + + it("focus item's first focusable", () => { + const elements = createElements(true); + document.body.append(...elements); + + expect(focusElementInGroup(elements, elements[0], "previous", true, false)).toBe(elements[2]); + expect(document.activeElement).toBe(elements[2].firstElementChild); + expect(focusElementInGroup(elements, elements[2], "next", true, false)).toBe(elements[0]); + expect(document.activeElement).toBe(elements[0].firstElementChild); + }); + }); + + it("allows specifying target as focus context", () => { + class Test extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = `
`; + } + + async setFocus(): Promise { + // simulate setFocus workflow + this.focus(); + } + } + + const testTag = registerTestElement(Test); + + const elements = Array.from({ length: 3 }, (_, index) => { + const el = document.createElement(testTag) as Test; + el.id = `item-${index}`; + el.tabIndex = 0; + document.body.appendChild(el); + return el; + }); + + // assertions only cover the focus context portion, the rest is covered by the previous tests + + expect(focusElementInGroup(elements, elements[0], "next", true, false)).toBe(elements[1]); + expect(document.activeElement).toBe(elements[1]); + expect(document.activeElement.shadowRoot.activeElement).toBe(null); + + expect(focusElementInGroup(elements, elements[0], "next", true, false, true)).toBe(elements[1]); + expect(document.activeElement).toBe(elements[1]); + expect(document.activeElement?.shadowRoot.activeElement).toBe(elements[1].shadowRoot.querySelector("#inner")); }); }); diff --git a/packages/calcite-components/src/utils/dom.ts b/packages/calcite-components/src/utils/dom.ts index 30f1823483e..d10c9cdee2e 100644 --- a/packages/calcite-components/src/utils/dom.ts +++ b/packages/calcite-components/src/utils/dom.ts @@ -1,5 +1,5 @@ // @ts-strict-ignore -import { tabbable } from "tabbable"; +import { focusable, tabbable } from "tabbable"; import { IconNameOrString } from "../components/icon/interfaces"; import { guid } from "./guid"; import { CSS_UTILITY } from "./resources"; @@ -264,36 +264,82 @@ export function isCalciteFocusable(el: FocusableElement): boolean { * This helper focuses an element using the `setFocus` method if available and falls back to using the `focus` method if not available. * * @param {Element} el An element. - */ -export async function focusElement(el: FocusableElement): Promise { + * @param includeContainer When true, the container element will be considered as well. Note, this is only applicable when `setFocus` is not applicable. + * @param strategy The focus strategy to use when finding the first focusable element. Defaults to "tabbable". + * @param context The element invoking the focus – use when the host is focusable to short-circuit the focus call. + */ +export async function focusElement( + el: FocusableElement, + includeContainer = false, + strategy: "focusable" | "tabbable" = "tabbable", + context?: HTMLElement, +): Promise { if (!el) { return; } - return isCalciteFocusable(el) ? el.setFocus() : el.focus(); + if (isCalciteFocusable(el) && context !== el) { + return el.setFocus(); + } + + const firstFocusFunction = strategy === "tabbable" ? focusFirstTabbable : focusFirstFocusable; + return firstFocusFunction(el, includeContainer); } /** * Helper to get the first tabbable element. * * @param {HTMLElement} element The html element containing tabbable elements. + * @param {boolean} includeContainer When true, the container element will be considered as well. + * * @returns the first tabbable element. */ -export function getFirstTabbable(element: HTMLElement): HTMLElement { +export function getFirstTabbable(element: HTMLElement, includeContainer?: boolean): HTMLElement { if (!element) { return; } - return (tabbable(element, tabbableOptions)[0] ?? element) as HTMLElement; + return (tabbable(element, { ...tabbableOptions, includeContainer })[0] ?? element) as HTMLElement; } /** * Helper to focus the first tabbable element. * * @param {HTMLElement} element The html element containing tabbable elements. + * @param {boolean} includeContainer When true, the container element will be considered as well. + */ +export function focusFirstTabbable(element: HTMLElement, includeContainer?: boolean): void { + getFirstTabbable(element, includeContainer)?.focus(); +} + +/** + * Helper to get the first focusable element. + * + * @param {HTMLElement} element The html element containing focusable elements. + * @param {boolean} includeContainer When true, the container element will be considered as well. + * + * @returns the first focusable element. + * + * @internal + */ +function getFirstFocusable(element: HTMLElement, includeContainer?: boolean): HTMLElement { + if (!element) { + return; + } + + return (focusable(element, { ...tabbableOptions, includeContainer })[0] ?? element) as HTMLElement; +} + +/** + * Helper to focus the first focusable element. + * + * @param {HTMLElement} element The html element containing focusable elements. + * @param {boolean} includeContainer When true, the container element will be considered as well. + * + * @internal */ -export function focusFirstTabbable(element: HTMLElement): void { - getFirstTabbable(element)?.focus(); +function focusFirstFocusable(element: HTMLElement, includeContainer?: boolean): void { + getFirstFocusable(element, includeContainer)?.focus(); } /** @@ -534,6 +580,8 @@ export type FocusElementInGroupDestination = "first" | "last" | "next" | "previo * @param {Element} currentElement The current element. * @param {FocusElementInGroupDestination} destination The target destination element to focus. * @param {boolean} cycle Should navigation cycle through elements or stop at extent - defaults to true. + * @param {boolean} includeContainer Determines whether the container element should be considered as well - defaults to true. + * @param targetAsContext * @returns {Element} The focused element */ export const focusElementInGroup = ( @@ -541,6 +589,8 @@ export const focusElementInGroup = ( currentElement: Element, destination: FocusElementInGroupDestination, cycle = true, + includeContainer = true, + targetAsContext = false, ): T => { const currentIndex = elements.indexOf(currentElement); const isFirstItem = currentIndex === 0; @@ -561,7 +611,7 @@ export const focusElementInGroup = ( focusTarget = elements[0]; } - focusElement(focusTarget); + focusElement(focusTarget, includeContainer, "tabbable", targetAsContext ? focusTarget : undefined); return focusTarget; }; diff --git a/packages/calcite-components/src/utils/interactive.browser.spec.tsx b/packages/calcite-components/src/utils/interactive.browser.spec.tsx new file mode 100644 index 00000000000..2bee7ad30df --- /dev/null +++ b/packages/calcite-components/src/utils/interactive.browser.spec.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { LitElement, property } from "@arcgis/lumina"; +import { mount } from "@arcgis/lumina-compiler/testing"; +import { updateHostInteraction } from "./interactive"; + +describe("interactive", () => { + it("updateHostInteraction", async () => { + class Test extends LitElement { + @property() + disabled = false; + } + + const { component, el } = await mount(Test); + + updateHostInteraction(component); + + expect(el.getAttribute("aria-disabled")).toBeNull(); + + component.disabled = true; + + updateHostInteraction(component); + + expect(el.getAttribute("aria-disabled")).toBe("true"); + }); +}); diff --git a/packages/calcite-components/src/utils/interactive.spec.ts b/packages/calcite-components/src/utils/interactive.spec.ts deleted file mode 100644 index 1dbe2ac31d7..00000000000 --- a/packages/calcite-components/src/utils/interactive.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-strict-ignore -import { describe, expect, it } from "vitest"; -import { InteractiveHTMLElement, updateHostInteraction } from "./interactive"; - -describe("interactive", () => { - it("updateHostInteraction", () => { - document.body.innerHTML = ` - - `; - - const fakeInteractiveEl = document.querySelector("fake-interactive"); - - const fakeInteractive = { - el: fakeInteractiveEl as InteractiveHTMLElement, - disabled: false, - }; - - updateHostInteraction(fakeInteractive); - - expect(fakeInteractiveEl.getAttribute("aria-disabled")).toBeNull(); - - fakeInteractive.disabled = true; - - updateHostInteraction(fakeInteractive); - - expect(fakeInteractiveEl.getAttribute("aria-disabled")).toBe("true"); - }); -}); diff --git a/packages/calcite-components/src/utils/interactive.tsx b/packages/calcite-components/src/utils/interactive.tsx index 6506b288493..d01e8afa17d 100644 --- a/packages/calcite-components/src/utils/interactive.tsx +++ b/packages/calcite-components/src/utils/interactive.tsx @@ -1,11 +1,8 @@ // @ts-strict-ignore import { TemplateResult } from "lit-html"; -import { h, JsxNode, LuminaJsx } from "@arcgis/lumina"; - -export interface InteractiveComponent { - /** The host element. */ - readonly el: InteractiveHTMLElement; +import { h, JsxNode, LitElement, LuminaJsx } from "@arcgis/lumina"; +export interface InteractiveComponent extends LitElement { /** * When true, prevents user interaction. *