From 32da6391509ef57058c47713f1d4a0f30f673d27 Mon Sep 17 00:00:00 2001 From: mkrause Date: Sun, 20 Apr 2025 14:16:34 +0100 Subject: [PATCH 1/8] Replace all instances of Dropdown with ListBox, and remove the Dropdown component. --- app/Demo.tsx | 4 +- app/lib.ts | 1 - .../forms/controls/Select/Select.stories.tsx | 16 +- .../forms/controls/Select/Select.tsx | 16 +- .../DialogModal/DialogModal.stories.tsx | 16 +- .../DropdownMenu/DropdownMenu.module.scss | 117 ----------- .../DropdownMenu/DropdownMenu.stories.tsx | 91 -------- .../overlays/DropdownMenu/DropdownMenu.tsx | 194 ------------------ .../DropdownMenuProvider.module.scss | 38 ++++ .../DropdownMenuProvider.stories.tsx | 36 ++-- .../DropdownMenu/DropdownMenuProvider.tsx | 71 ++++--- .../pagination/PaginationSizeSelector.tsx | 5 +- src/layouts/AppLayout/AppLayout.stories.tsx | 30 ++- .../Header/AccountSelector.stories.tsx | 19 +- .../AppLayout/Header/AccountSelector.tsx | 25 ++- 15 files changed, 181 insertions(+), 498 deletions(-) delete mode 100644 src/components/overlays/DropdownMenu/DropdownMenu.module.scss delete mode 100644 src/components/overlays/DropdownMenu/DropdownMenu.stories.tsx delete mode 100644 src/components/overlays/DropdownMenu/DropdownMenu.tsx create mode 100644 src/components/overlays/DropdownMenu/DropdownMenuProvider.module.scss diff --git a/app/Demo.tsx b/app/Demo.tsx index 6f7c8b68..2dec3b6d 100644 --- a/app/Demo.tsx +++ b/app/Demo.tsx @@ -27,7 +27,9 @@ export const Demo = () => {
{/* */} - + + {accountSelected => accountSelected ?? 'Accounts'} +
diff --git a/app/lib.ts b/app/lib.ts index 121115ab..a5a9e837 100644 --- a/app/lib.ts +++ b/app/lib.ts @@ -79,7 +79,6 @@ export { Tab, Tabs } from '../src/components/navigations/Tabs/Tabs.tsx'; export { SpinnerModal } from '../src/components/overlays/SpinnerModal/SpinnerModal.tsx'; export { DialogModal } from '../src/components/overlays/DialogModal/DialogModal.tsx'; export { DialogOverlay } from '../src/components/overlays/DialogOverlay/DialogOverlay.tsx'; -export { DropdownMenu } from '../src/components/overlays/DropdownMenu/DropdownMenu.tsx'; export { DropdownMenuProvider } from '../src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx'; export { ToastProvider, notify } from '../src/components/overlays/ToastProvider/ToastProvider.tsx'; export { Tooltip } from '../src/components/overlays/Tooltip/Tooltip.tsx'; diff --git a/src/components/forms/controls/Select/Select.stories.tsx b/src/components/forms/controls/Select/Select.stories.tsx index caeeda86..1e71d5be 100644 --- a/src/components/forms/controls/Select/Select.stories.tsx +++ b/src/components/forms/controls/Select/Select.stories.tsx @@ -31,14 +31,14 @@ export const Standard: Story = { args: { children: ( <> - - - - - - - - + + + + + + + + ), }, diff --git a/src/components/forms/controls/Select/Select.tsx b/src/components/forms/controls/Select/Select.tsx index e6bcd1e5..73a9669e 100644 --- a/src/components/forms/controls/Select/Select.tsx +++ b/src/components/forms/controls/Select/Select.tsx @@ -19,11 +19,11 @@ import cl from './Select.module.scss'; export { cl as SelectClassNames }; -export type OptionKey = string; -export type OptionDef = { optionKey: OptionKey, label: string }; +export type itemKey = string; +export type OptionDef = { itemKey: itemKey, label: string }; export type SelectContext = { - selectedOption: null | OptionKey, + selectedOption: null | itemKey, selectOption: (option: OptionDef) => void, getItemProps: ReturnType['getItemProps'], }; @@ -39,7 +39,7 @@ export const useSelectContext = () => { export type OptionProps = React.PropsWithChildren & { /** A unique identifier for this option. */ - optionKey: OptionKey, + itemKey: itemKey, /** The human-readable label to be shown. */ label: string, @@ -48,12 +48,12 @@ export type OptionProps = React.PropsWithChildren * Form control to select an item from a list of options through a dropdown. */ export const Option = (props: OptionProps) => { - const { optionKey, label, ...propsRest } = props; + const { itemKey, label, ...propsRest } = props; const { selectedOption, selectOption, getItemProps } = useSelectContext(); - const option: OptionDef = { optionKey, label }; - const isSelected = typeof selectedOption === 'string' && selectedOption === optionKey; + const option: OptionDef = { itemKey, label }; + const isSelected = typeof selectedOption === 'string' && selectedOption === itemKey; return (
  • @@ -121,7 +121,7 @@ export const Select = Object.assign( }); const context: SelectContext = React.useMemo(() => ({ - selectedOption: selected?.optionKey ?? null, + selectedOption: selected?.itemKey ?? null, selectOption: (option: OptionDef) => { setSelected(option); setIsOpen(false); diff --git a/src/components/overlays/DialogModal/DialogModal.stories.tsx b/src/components/overlays/DialogModal/DialogModal.stories.tsx index 67a7ac86..ca33c256 100644 --- a/src/components/overlays/DialogModal/DialogModal.stories.tsx +++ b/src/components/overlays/DialogModal/DialogModal.stories.tsx @@ -123,7 +123,21 @@ export const DialogModalWithDropdown: Story = { children: ( <>

    The following dropdown menu should overlay the modal (and not be cut off).

    - + + {Array.from({ length: 30 }, (_, index) => `Account ${index + 1}`).map(name => + + )} + + {}}/> + + + } + > + {selectedAccount => selectedAccount === null ? 'Accounts' : selectedAccount.replace(/^acc_/, '')} + ), }, diff --git a/src/components/overlays/DropdownMenu/DropdownMenu.module.scss b/src/components/overlays/DropdownMenu/DropdownMenu.module.scss deleted file mode 100644 index a3a381d9..00000000 --- a/src/components/overlays/DropdownMenu/DropdownMenu.module.scss +++ /dev/null @@ -1,117 +0,0 @@ -/* Copyright (c) Fortanix, Inc. -|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -@use '../../../styling/defs.scss' as bk; - -@layer baklava.components { - $bk-dropdown-menu-min-width: 14rem; - - .bk-dropdown-menu { - @include bk.component-base(bk-dropdown-menu); - - --bk-dropdown-menu-transition-duration: 150ms; - - overflow: hidden; - /* stylelint-disable-next-line declaration-property-value-disallowed-list */ - overflow-y: auto; - - min-inline-size: $bk-dropdown-menu-min-width; - max-block-size: 18rem; - padding-block-end: 8px; - - background: bk.$theme-dropdown-menu-menu-background-default; - border: 1px solid bk.$theme-dropdown-menu-menu-border-default; - - border-radius: 4px; - &[data-placement^="bottom"] { - border-start-start-radius: 0; - border-start-end-radius: 0; - } - &[data-placement^="top"] { - border-end-start-radius: 0; - border-end-end-radius: 0; - } - - display: flex; - flex-direction: column; - align-items: stretch; - - &[popover] { - &:not(:popover-open) { display: none; } - - @media (prefers-reduced-motion: no-preference) { - transition: - display var(--bk-dropdown-menu-transition-duration) allow-discrete, - overlay var(--bk-dropdown-menu-transition-duration) allow-discrete, - opacity var(--bk-dropdown-menu-transition-duration) ease-out, - translate var(--bk-dropdown-menu-transition-duration) ease-out; - /* transform */ /* Note: don't animate `transform` with floating-ui, since it relies on it for positioning */ - opacity: 0; - translate: 0 -1rem; - } - - &:popover-open { - opacity: 1; - translate: none; - - @media (prefers-reduced-motion: no-preference) { - @starting-style { - opacity: 0; - translate: 0 -1rem; - } - } - } - } - - > li { - display: flex; - flex-direction: column; - align-items: stretch; - - &:not(:first-child) { - border-block-start: 1px solid bk.$theme-dropdown-menu-menu-border-default; - } - - &:hover { - background: bk.$theme-dropdown-menu-menu-background-hover; - } - &:has(> [aria-selected="true"]) { - background: bk.$theme-dropdown-menu-menu-background-hover; - box-shadow: inset 5px 0 bk.$theme-dropdown-menu-menu-tab-default; - } - - @media (prefers-reduced-motion: no-preference) { - transition: box-shadow 150ms ease-in; - } - - .bk-dropdown-menu__item { - padding: 8px 14px; - - @include bk.font(bk.$font-family-body); - font-size: 14px; - line-height: 24px; - - display: flex; - align-items: center; - gap: bk.$spacing-3; - - .bk-dropdown-menu__item__icon { - font-size: 1.2em; - } - .bk-dropdown-menu__item__label { - user-select: none; - } - - @include bk.focus-inset; - } - } - - &[popover] > li { - @media (prefers-reduced-motion: no-preference) { - /* Delay transition until after close is complete to make it less jarring during close animation */ - transition-delay: var(--bk-dropdown-menu-transition-duration); - } - } - } -} diff --git a/src/components/overlays/DropdownMenu/DropdownMenu.stories.tsx b/src/components/overlays/DropdownMenu/DropdownMenu.stories.tsx deleted file mode 100644 index ead917c0..00000000 --- a/src/components/overlays/DropdownMenu/DropdownMenu.stories.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* Copyright (c) Fortanix, Inc. -|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import type { Meta, StoryObj } from '@storybook/react'; - -import * as React from 'react'; - -import { DropdownMenu, type OptionKey, type OptionDef, DropdownMenuContext } from './DropdownMenu.tsx'; - - -type DropdownMenuArgs = React.ComponentProps; -type Story = StoryObj; - -export default { - component: DropdownMenu, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], - argTypes: { - }, - args: { - label: 'Test dropdown', - }, - render: (args) => , -} satisfies Meta; - - -type DropdownMenuControlledProps = React.PropsWithChildren>; -const DropdownMenuControlled = ({ children, ...dropdownContext }: DropdownMenuControlledProps) => { - const [selectedOption, setSelectedOption] = React.useState(null); - const context: DropdownMenuContext = { - selectedOption, - selectOption: (option: OptionDef) => { setSelectedOption(option.optionKey); }, - close: () => {}, - ...dropdownContext, - }; - - return ( - - {children} - - ); -}; - -export const Standard: Story = { - name: 'DropdownMenu', - decorators: [Story => ], - args: { - children: ( - <> - - - - - - - - - - - ), - }, -}; - -export const WithActions: Story = { - decorators: [Story => ], - args: { - children: ( - <> - {}}/> - {}}/> - - ), - }, -}; - -export const WithActionsAndOptions: Story = { - decorators: [Story => ], - args: { - children: ( - <> - - - {}}/> - {}}/> - - ), - }, -}; diff --git a/src/components/overlays/DropdownMenu/DropdownMenu.tsx b/src/components/overlays/DropdownMenu/DropdownMenu.tsx deleted file mode 100644 index 28a39d6f..00000000 --- a/src/components/overlays/DropdownMenu/DropdownMenu.tsx +++ /dev/null @@ -1,194 +0,0 @@ -/* Copyright (c) Fortanix, Inc. -|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of -|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import * as React from 'react'; -import { classNames as cx, type ComponentProps } from '../../../util/componentUtil.ts'; - -import { type IconName, Icon } from '../../graphics/Icon/Icon.tsx'; -import { Button } from '../../actions/Button/Button.tsx'; - -import cl from './DropdownMenu.module.scss'; - - -/* -References: -- https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role -*/ - -export { cl as DropdownMenuClassNames }; - -export type OptionKey = string; -export type OptionDef = { optionKey: OptionKey, label: string }; - -export type OptionState = { option: OptionDef, selected: boolean }; -export type DropdownMenuContext = { - // Additional props to pass to the option - optionProps?: undefined | ((optionState: OptionState) => Record), - - selectedOption: null | OptionKey, // The key of the currently selected option (if any) - selectOption: (option: OptionDef) => void, // Select the given option - - close: () => void, // Request dropdown menu close -}; -export const DropdownMenuContext = React.createContext(null); -export const useDropdownMenuContext = (): DropdownMenuContext => { - const context = React.use(DropdownMenuContext); - if (context === null) { throw new Error(`Missing DropdownMenuContext provider`); } - return context; -}; - - -export type ActionProps = ComponentProps & { - /** A unique identifier for this action. */ - itemKey: OptionKey, - - /** The human-readable label to be shown. */ - label: string, - - /** The icon to be displayed (if any). */ - icon?: undefined | IconName, - - /** The event handler for when the user activates this action. */ - onActivate: (context: DropdownMenuContext) => void | Promise, -}; -/** - * A dropdown menu item that can be triggered to perform some action. - */ -export const Action = (props: ActionProps) => { - const { itemKey, label, icon, onActivate, ...propsRest } = props; - - const context = useDropdownMenuContext(); - const { optionProps, selectedOption } = context; - - const option: OptionDef = { optionKey: itemKey, label }; - const isSelected = selectedOption === itemKey; - - return ( -
  • - -
  • - ); -}; - - -export type OptionProps = React.PropsWithChildren & { - /** A unique identifier for this option. */ - optionKey: OptionKey, - - /** The human-readable label to be shown. */ - label: string, - - /** The icon to be displayed (if any) */ - icon?: undefined | IconName, - - /** A callback to be called when the option is selected. */ - onSelect?: undefined | (() => void), -}>; -/** - * A dropdown menu item that can be selected. - */ -export const Option = (props: OptionProps) => { - const { optionKey, label, icon, onSelect, ...propsRest } = props; - const { selectedOption, selectOption, optionProps } = useDropdownMenuContext(); - - const option: OptionDef = { optionKey, label }; - const isSelected = selectedOption === optionKey; - - return ( -
  • - -
  • - ); -}; - -export type DropdownMenuProps = ComponentProps<'ul'> & { - /** Whether this component should be unstyled. */ - unstyled?: undefined | boolean, - - /** An accessible name for this dropdown menu. Required. */ - label: string, -}; -/** - * Dropdown menu with a list of selectable options. - */ -export const DropdownMenu = Object.assign( - (props: DropdownMenuProps) => { - const { children, unstyled = false, label, ...propsRest } = props; - - // FIXME: need to implement keyboard arrow (up/down) navigation through items, as per: - // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role - return ( -
      ` as interactive. - // biome-ignore lint/a11y/useSemanticElements: Cannot (yet) use `` element. */ + inputProps?: React.ComponentProps<'input'>, + /** An icon to show before the input. */ icon?: undefined | IconName, - + /** The accessible name for the icon. */ iconLabel?: undefined | string, @@ -53,7 +59,7 @@ export type InputProps = Omit, 'type'> & { * Note that browser support is still somewhat limited: * https://developer.mozilla.org/en-US/docs/Web/CSS/field-sizing */ - automaticResize?: undefined | boolean, + automaticResize?: undefined | boolean, }; /** * Input control. @@ -61,10 +67,12 @@ export type InputProps = Omit, 'type'> & { export const Input = Object.assign( (props: InputProps) => { const { - unstyled = false, className, - inputClassName, + ref, + unstyled = false, type = 'text', + containerProps = {}, + inputProps = {}, icon, iconLabel, actions, @@ -72,6 +80,9 @@ export const Input = Object.assign( ...propsRest } = props; + // Split props into container-specific and input-specific + const propsExtracted = InputUtil.extractInputSpecificProps(propsRest); + const inputRef = React.useRef(null); // When the user clicks on the container, focus the input @@ -84,8 +95,11 @@ export const Input = Object.assign( }, []); // Prevent inputs from being used as (form submit) buttons - //if (type === 'button' || type === 'submit' || type === 'image' || type === 'reset') { - if (['button', 'submit', 'image', 'reset'].includes(type)) { + const bannedTypes = ['button', 'submit', 'image', 'reset']; + if (bannedTypes.includes(type)) { + throw new Error(`Input: unsupported type '${type}'.`); + } + if (inputProps.type && bannedTypes.includes(inputProps.type)) { throw new Error(`Input: unsupported type '${type}'.`); } @@ -96,20 +110,27 @@ export const Input = Object.assign( return (
      {icon && } {actions}
      diff --git a/src/components/forms/controls/ListBox/ListBox.module.scss b/src/components/forms/controls/ListBox/ListBox.module.scss index df0563a4..740384ee 100644 --- a/src/components/forms/controls/ListBox/ListBox.module.scss +++ b/src/components/forms/controls/ListBox/ListBox.module.scss @@ -97,13 +97,15 @@ &:is(:disabled, .bk-list-box__item--disabled) { color: bk.$theme-text-small-text-subtle; } - &:focus-visible { + &:focus { background: bk.$theme-dropdown-menu-menu-background-focused; - // stylelint-disable-next-line declaration-no-important -- Override accessibility layer - outline: var(--bk-focus-outline-width) solid var(--bk-focus-outline-color) !important; - // stylelint-disable-next-line declaration-no-important -- Override accessibility layer - outline-offset: calc(-1 * var(--bk-focus-outline-width)) !important; + &:focus-visible { + // stylelint-disable-next-line declaration-no-important -- Override accessibility layer + outline: var(--bk-focus-outline-width) solid var(--bk-focus-outline-color) !important; + // stylelint-disable-next-line declaration-no-important -- Override accessibility layer + outline-offset: calc(-1 * var(--bk-focus-outline-width)) !important; + } } &.bk-list-box__item--option { diff --git a/src/components/forms/controls/ListBox/ListBox.stories.tsx b/src/components/forms/controls/ListBox/ListBox.stories.tsx index acd7368a..31d45c8a 100644 --- a/src/components/forms/controls/ListBox/ListBox.stories.tsx +++ b/src/components/forms/controls/ListBox/ListBox.stories.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { notify } from '../../../overlays/ToastProvider/ToastProvider.tsx'; import { Button } from '../../../actions/Button/Button.tsx'; -import { type ItemKey, ListBox } from './ListBox.tsx'; +import { type ItemDetails, ListBox } from './ListBox.tsx'; const notifyPressed = () => { notify.info('Pressed the item'); }; @@ -27,7 +27,6 @@ export default { }, args: { label: 'Test list box', - //onSelect: item => { console.log('x', item); }, }, render: (args) => , } satisfies Meta; @@ -237,12 +236,12 @@ export const ListBoxMany: Story = { type ListBoxControlledProps = Omit, 'selected'>; const ListBoxControlledC = (props: ListBoxControlledProps) => { - const [selectedItem, setSelectedItem] = React.useState(undefined); + const [selectedItem, setSelectedItem] = React.useState(null); return ( <> -

      Selected fruit: {selectedItem ?? none}

      - +

      Selected fruit: {selectedItem?.label ?? none}

      + ); }; diff --git a/src/components/forms/controls/ListBox/ListBox.tsx b/src/components/forms/controls/ListBox/ListBox.tsx index 3f7b07e9..da9b016c 100644 --- a/src/components/forms/controls/ListBox/ListBox.tsx +++ b/src/components/forms/controls/ListBox/ListBox.tsx @@ -13,6 +13,7 @@ import { Button } from '../../../actions/Button/Button.tsx'; import { type ItemKey, type ItemDef, + type ItemDetails, type ItemWithKey, type VirtualItemKeys, ListBoxContext, @@ -32,7 +33,7 @@ References: - https://www.radix-ui.com/primitives/docs/components/select */ -export { type ItemKey, type ItemDef, ListBoxContext, useListBoxItem }; +export { type ItemKey, type ItemDef, type ItemDetails, ListBoxContext, useListBoxItem }; export { cl as ListBoxClassNames }; @@ -218,10 +219,10 @@ export type ListBoxProps = Omit, 'onSelect'> & { defaultSelected?: undefined | ItemKey, /** The option to select. If `undefined`, this component will be considered uncontrolled. */ - selected?: undefined | ItemKey, + selected?: undefined | null | ItemKey, /** Event handler to be called when the selected option state changes. */ - onSelect?: undefined | ((itemKey: ItemKey) => void), + onSelect?: undefined | ((selectedItem: null | ItemDetails) => void), /** Whether the list box is disabled or not. Default: false. */ disabled?: undefined | boolean, @@ -238,6 +239,9 @@ export type ListBoxProps = Omit, 'onSelect'> & { /** Any additional props to apply to the internal ``. */ inputProps?: undefined | Omit, 'value' | 'onChange'>, + /** Render the given item key as a string label. If not given, will use the item element's text value. */ + formatItemLabel?: undefined | ((itemKey: ItemKey) => string), + /** If the list is virtually rendered, `virtualItemKeys` should be provided with the full list of item keys. */ virtualItemKeys?: undefined | null | VirtualItemKeys, }; @@ -275,7 +279,7 @@ export const ListBox = Object.assign( unstyled = false, label, defaultSelected, - selected, + selected = null, onSelect, disabled = false, name, @@ -283,6 +287,7 @@ export const ListBox = Object.assign( form, inputProps, virtualItemKeys = null, + formatItemLabel, ...propsRest } = props; @@ -315,24 +320,29 @@ export const ListBox = Object.assign( React.useEffect(() => { return listBox.store.subscribe((state, prevState) => { if (state.selectedItem !== prevState.selectedItem && state.selectedItem !== null) { - onSelect?.(state.selectedItem); + const itemKey = state.selectedItem; + const label: string = formatItemLabel?.(itemKey) + ?? state._internalItemsRegistry.get(itemKey)?.itemRef.current?.textContent + ?? itemKey; + const selectedItem: null | ItemDetails = state.selectedItem === null ? null : { + itemKey, + label, + }; + + onSelect?.(selectedItem); } }); - }, [listBox.store, onSelect]); + }, [listBox.store, onSelect, formatItemLabel]); - const handleKeyDownCapture = React.useCallback((event: React.KeyboardEvent) => { + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { if (event.key === 'Enter') { - // Prevent the child `Button` press handler from being triggered, since pressing Enter should submit the form, - // not trigger the default click/select behavior. - event.preventDefault(); - event.stopPropagation(); - const formId = inputRef.current?.getAttribute('form'); if (!formId) { return; } const form = document.getElementById(formId); if (form instanceof HTMLFormElement) { - form.requestSubmit(); + // Submit the form (after a timeout to allow the `` to be updated in response to the Enter key event) + window.setTimeout(() => { form.requestSubmit(); }, 0); } } }, []); @@ -349,8 +359,7 @@ export const ListBox = Object.assign( {...propsRest} {...listBox.props} ref={mergeRefs(ref, props.ref)} - onKeyDownCapture={handleKeyDownCapture} // Note: run in capture phase so we can prevent the `Button` handler - onKeyDown={mergeCallbacks([listBox.props.onKeyDown, propsRest.onKeyDown])} + onKeyDown={mergeCallbacks([handleKeyDown, listBox.props.onKeyDown, propsRest.onKeyDown])} onToggle={mergeCallbacks([listBox.props.onToggle, props.onToggle])} className={cx( scrollerProps.className, diff --git a/src/components/forms/controls/ListBox/ListBoxStore.tsx b/src/components/forms/controls/ListBox/ListBoxStore.tsx index ac4998b1..732398bb 100644 --- a/src/components/forms/controls/ListBox/ListBoxStore.tsx +++ b/src/components/forms/controls/ListBox/ListBoxStore.tsx @@ -25,6 +25,11 @@ export type ItemDef = { export type ItemMap = Map; export type ItemWithKey = ItemDef & { itemKey: ItemKey }; +export type ItemDetails = { + itemKey: ItemKey, + label: string, +}; + /** * The minimal subtype of `Array` that we need to be able to render a virtualized list. We keep it minimal * so that the consumer may compute this information dynamically rather than storing it all in memory. For best @@ -288,11 +293,17 @@ export const useListBox = ( if (document.activeElement instanceof HTMLElement) { previousActiveElementRef.current = document.activeElement; } - focusedElement.itemRef?.current?.focus(); + focusedElement.itemRef?.current?.focus({ + // @ts-ignore Supported in some browsers (e.g. Firefox). + focusVisible: false, + }); } else if (event.oldState === 'open' && event.newState === 'closed') { const previousActiveElement = previousActiveElementRef.current; if (previousActiveElement) { - previousActiveElement.focus(); + previousActiveElement.focus({ + // @ts-ignore Supported in some browsers (e.g. Firefox). + focusVisible: false, + }); } } }, []); diff --git a/src/components/forms/controls/Select/Select.module.scss b/src/components/forms/controls/Select/Select.module.scss index ea1a143d..c493b18f 100644 --- a/src/components/forms/controls/Select/Select.module.scss +++ b/src/components/forms/controls/Select/Select.module.scss @@ -11,108 +11,28 @@ .bk-select { @include bk.component-base(bk-select); - /* Make sure the content does not have any extra space (e.g. under the line) due to baseline alignment */ - display: flex; - - --bk-input-accent-color: #{bk.$theme-form-rule-default}; - border: 0 solid var(--bk-input-accent-color); - border-block-end-width: 1px; - - block-size: 24px; + cursor: pointer; .bk-select__input { - cursor: pointer; - min-inline-size: $bk-select-min-inline-size; - caret-color: transparent; /* FIXME: find better way to manage the input state */ - border: none; - } - - &.bk-select--open .bk-select__input__arrow { - rotate: 0.5turn; - } - } - - .bk-select__dropdown { - min-inline-size: calc($bk-select-min-inline-size + $bk-caret-size); - max-block-size: 18rem; - - background: bk.$theme-dropdown-menu-menu-background-default; - border: 1px solid bk.$theme-dropdown-menu-menu-border-default; - - border-radius: 4px; - &[data-placement="bottom"] { - border-start-start-radius: 0; - border-start-end-radius: 0; - } - &[data-placement="top"] { - border-end-start-radius: 0; - border-end-end-radius: 0; - } - - display: flex; - &:not(:popover-open) { display: none; } - flex-direction: column; - align-items: stretch; - - > li { - display: flex; - flex-direction: column; - align-items: stretch; + user-select: none; - &:not(:first-child) { - border-block-start: 1px solid bk.$theme-dropdown-menu-menu-border-default; - } + min-inline-size: 12ch; - &:last-child { - padding-block-end: bk.$spacing-2; + // Note: `user-select` does not seem to work for `input`, instead we can make the highlight color transparent + &::selection { + background-color: transparent; } - - &:hover { - background: bk.$theme-dropdown-menu-menu-background-hover; - } - &[aria-selected="true"] { - background: bk.$theme-dropdown-menu-menu-background-hover; - box-shadow: inset 5px 0 bk.$theme-dropdown-menu-menu-tab-default; - } - - @media (prefers-reduced-motion: no-preference) { - /* Add a transition to make the switch to selected state less jarring during close animation */ - transition: box-shadow 300ms ease-in; - } - } - - @media (prefers-reduced-motion: no-preference) { - --transition-duration: 150ms; - transition: - display var(--transition-duration) allow-discrete, - overlay var(--transition-duration) allow-discrete, - opacity var(--transition-duration) ease-out, - translate var(--transition-duration) ease-out; - /* transform */ /* Note: don't animate `transform` with floating-ui, since it relies on it for positioning */ - opacity: 0; - translate: 0 -1rem; } - &:popover-open { - opacity: 1; - translate: none; + .bk-select__arrow { + pointer-events: none; // Let the container handle pointer events instead - @starting-style { - opacity: 0; - translate: 0 -1rem; + @media (prefers-reduced-motion: no-preference) { + transition: rotate 150ms ease-in-out; } } - } - - .bk-select__option { - padding: 8px 14px 8px 26px; - - @include bk.font(bk.$font-family-body); - font-size: 14px; - line-height: 24px; - - @include bk.focus-inset; - - display: flex; + &.bk-select--open .bk-select__arrow { + rotate: x 0.5turn; + } } } diff --git a/src/components/forms/controls/Select/Select.stories.tsx b/src/components/forms/controls/Select/Select.stories.tsx index 1e71d5be..27dfbb39 100644 --- a/src/components/forms/controls/Select/Select.stories.tsx +++ b/src/components/forms/controls/Select/Select.stories.tsx @@ -6,6 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import * as React from 'react'; +import { notify } from '../../../overlays/ToastProvider/ToastProvider.tsx'; import { Select } from './Select.tsx'; @@ -26,19 +27,42 @@ export default { } satisfies Meta; -export const Standard: Story = { - name: 'Select', +export const SelectStandard: Story = { args: { - children: ( + options: ( <> - - - - - - - - + {Array.from({ length: 8 }, (_, i) => i + 1).map(index => + + )} + + ), + }, +}; + +export const SelectInForm: Story = { + decorators: [ + Story => ( + <> +
      { + event.preventDefault(); + notify.info(`You have chosen: ${new FormData(event.currentTarget).get('story_component1') || 'none'}`); + }} + /> + + + + ), + ], + args: { + form: 'story-form', + name: 'story_component1', + options: ( + <> + {Array.from({ length: 8 }, (_, i) => i + 1).map(index => + + )} ), }, diff --git a/src/components/forms/controls/Select/Select.tsx b/src/components/forms/controls/Select/Select.tsx index 73a9669e..d10c743d 100644 --- a/src/components/forms/controls/Select/Select.tsx +++ b/src/components/forms/controls/Select/Select.tsx @@ -3,15 +3,14 @@ |* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { mergeRefs } from '../../../../util/reactUtil.ts'; +import { mergeCallbacks } from '../../../../util/reactUtil.ts'; import { classNames as cx, type ComponentProps } from '../../../../util/componentUtil.ts'; -import { useScroller } from '../../../../layouts/util/Scroller.tsx'; -import { useFloatingElement } from '../../../util/overlays/floating-ui/useFloatingElement.tsx'; -import { useListNavigation, useInteractions } from '@floating-ui/react'; -import { Icon } from '../../../graphics/Icon/Icon.tsx'; -import { Button } from '../../../actions/Button/Button.tsx'; import { Input } from '../Input/Input.tsx'; +import { + type ItemKey, + DropdownMenuProvider, +} from '../../../../components/overlays/DropdownMenu/DropdownMenuProvider.tsx'; import cl from './Select.module.scss'; @@ -19,155 +18,94 @@ import cl from './Select.module.scss'; export { cl as SelectClassNames }; -export type itemKey = string; -export type OptionDef = { itemKey: itemKey, label: string }; +/* +A `Select` is a single-select non-editable combobox. -export type SelectContext = { - selectedOption: null | itemKey, - selectOption: (option: OptionDef) => void, - getItemProps: ReturnType['getItemProps'], -}; -export const SelectContext = React.createContext(null); -export const useSelectContext = () => { - const context = React.use(SelectContext); - if (context === null) { - throw new Error(`Missing SelectContext provider`); - } - return context; -}; - - -export type OptionProps = React.PropsWithChildren & { - /** A unique identifier for this option. */ - itemKey: itemKey, - - /** The human-readable label to be shown. */ - label: string, -}>; -/** - * Form control to select an item from a list of options through a dropdown. - */ -export const Option = (props: OptionProps) => { - const { itemKey, label, ...propsRest } = props; - - const { selectedOption, selectOption, getItemProps } = useSelectContext(); - - const option: OptionDef = { itemKey, label }; - const isSelected = typeof selectedOption === 'string' && selectedOption === itemKey; - - return ( -
    • - -
    • - ); -}; +References: +- [1] https://www.w3.org/WAI/ARIA/apg/patterns/combobox +*/ -export type SelectProps = Omit, 'children'> & { - children: React.ReactElement | Array>, - +export type SelectProps = ComponentProps & { /** Whether this component should be unstyled. */ unstyled?: undefined | boolean, - /** Whether the select control can be searched by typing in the input. */ - searchable?: undefined | boolean, + /** The selected account. To access the selected account, pass a render prop. */ + children: (selectedAccount: null | ItemKey) => React.ReactNode, + + /** The options list to be shown in the dropdown menu. */ + options: React.ReactNode, }; -/** - * Form control to select an item from a dropdown of options. - */ export const Select = Object.assign( (props: SelectProps) => { - const { children, unstyled = false, searchable, ...propsRest } = props; - - const scrollerProps = useScroller(); - const selectedRef = React.useRef>(null); - const [selected, setSelected] = React.useState(null); - - const listRef = React.useRef([]); - const [activeIndex, setActiveIndex] = React.useState(null); const { - refs, - placement, - floatingStyles, - getReferenceProps, - getFloatingProps, - getItemProps, - isOpen, - setIsOpen, - } = useFloatingElement({ - placement: 'bottom', - floatingUiFlipOptions: { - fallbackAxisSideDirection: 'none', - fallbackStrategy: 'initialPlacement', - }, - floatingUiInteractions: context => [ - useListNavigation(context, { - listRef, - activeIndex, - onNavigate: setActiveIndex, - }), - ], - }); + unstyled = false, + children, + options, + name, + form, + ...propsRest + } = props; - const context: SelectContext = React.useMemo(() => ({ - selectedOption: selected?.itemKey ?? null, - selectOption: (option: OptionDef) => { - setSelected(option); - setIsOpen(false); - selectedRef.current?.focus(); // Return focus - }, - getItemProps, - }), [selected, setIsOpen, getItemProps]); + const handleKeyDown = React.useCallback((requestOpen: () => void) => (event: React.KeyboardEvent) => { + if (['ArrowUp', 'ArrowDown', ' '].includes(event.key)) { + event.preventDefault(); // Prevent scrolling + requestOpen(); + } + }, []); - // FIXME: implement `role="listbox" and associated `aria-` attributes - // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role return ( - - -
        - {children} -
      -
      + + {({ props, open, requestOpen, selectedOption }) => { + const anchorProps = props({ + placeholder: 'Select an option', + 'aria-disabled': true, + readOnly: true, // Make the input non-editable, but still focusable + ...propsRest, + className: cx(cl['bk-select'], { [cl['bk-select--open']]: open }), + value: selectedOption === null ? '' : selectedOption.label, + onChange: () => {}, + onKeyDown: mergeCallbacks([handleKeyDown(requestOpen), propsRest.onKeyDown]), + }); + + return ( + <> + {}} + /> + } + /> + {/* Render a hidden input with the selected option key (rather than the human-readable label). */} + + + ); + }} + ); }, - { Option }, + { + Header: DropdownMenuProvider.Header, + Option: DropdownMenuProvider.Option, + Action: DropdownMenuProvider.Action, + FooterActions: DropdownMenuProvider.FooterActions, + }, ); diff --git a/src/components/overlays/DropdownMenu/DropdownMenuProvider.module.scss b/src/components/overlays/DropdownMenu/DropdownMenuProvider.module.scss index 26d39b3a..b51e232b 100644 --- a/src/components/overlays/DropdownMenu/DropdownMenuProvider.module.scss +++ b/src/components/overlays/DropdownMenu/DropdownMenuProvider.module.scss @@ -19,7 +19,7 @@ translate var(--bk-dropdown-menu-transition-duration) ease-out; /* transform */ /* Note: don't animate `transform` with floating-ui, since it relies on it for positioning */ opacity: 0; - translate: 0 -1rem; + //translate: 0 -1rem; } &:popover-open { @@ -29,7 +29,7 @@ @media (prefers-reduced-motion: no-preference) { @starting-style { opacity: 0; - translate: 0 -1rem; + //translate: 0 -1rem; } } } diff --git a/src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx b/src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx index d94a0246..e1e46bf0 100644 --- a/src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx +++ b/src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx @@ -3,11 +3,14 @@ |* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { mergeRefs } from '../../../util/reactUtil.ts'; +import { mergeCallbacks, mergeRefs } from '../../../util/reactUtil.ts'; import { classNames as cx, type ComponentProps } from '../../../util/componentUtil.ts'; +import { useDebounce } from '../../../util/hooks/useDebounce.ts'; -import { type Placement } from '@floating-ui/react'; -import { useFloatingElement } from '../../util/overlays/floating-ui/useFloatingElement.tsx'; +import { + type UseFloatingElementOptions, + useFloatingElement, +} from '../../util/overlays/floating-ui/useFloatingElement.tsx'; import * as ListBox from '../../forms/controls/ListBox/ListBox.tsx'; @@ -16,13 +19,15 @@ import cl from './DropdownMenuProvider.module.scss'; export type ItemKey = ListBox.ItemKey; +type ListBoxProps = ComponentProps; export type AnchorRenderArgs = { props: (userProps?: undefined | React.HTMLProps) => Record, open: boolean, + requestOpen: () => void, // FIXME: better naming close: () => void, - selectedOption: undefined | ListBox.ItemKey, + selectedOption: null | ListBox.ItemDetails, }; -export type DropdownMenuProviderProps = Omit, 'children' | 'label'> & { +export type DropdownMenuProviderProps = Omit & { /** An accessible name for this dropdown menu. Required */ label: string, @@ -38,11 +43,26 @@ export type DropdownMenuProviderProps = Omit(null); const listBoxRef = React.useRef>(null); + const listBoxId = `listbox-${React.useId()}`; const { refs, @@ -72,15 +97,19 @@ export const DropdownMenuProvider = Object.assign( isOpen, setIsOpen, } = useFloatingElement({ - placement: placement, - offset: 8, + role, + keyboardInteractions, + placement, + offset, floatingUiFlipOptions: { fallbackAxisSideDirection: 'none', fallbackStrategy: 'initialPlacement', }, }); - const [selectedOption, setSelectedOption] = React.useState(); + const [shouldMountDropdown] = useDebounce(isOpen, isOpen ? 0 : 1000); + + const [selectedOption, setSelectedOption] = React.useState(null); const renderAnchor = () => { const anchorProps: AnchorRenderArgs['props'] = (userProps?: undefined | React.HTMLProps) => { @@ -94,11 +123,20 @@ export const DropdownMenuProvider = Object.assign( return { ...getReferenceProps(userProps), ref: userPropsRef ? mergeRefs(anchorRef, userPropsRef, refs.setReference) : refs.setReference, + 'aria-controls': listBoxId, + 'aria-haspopup': 'listbox', + 'aria-expanded': isOpen, }; }; if (typeof children === 'function') { - return children({ props: anchorProps, open: isOpen, close: () => { setIsOpen(false); }, selectedOption }); + return children({ + props: anchorProps, + open: isOpen, + requestOpen: () => { setIsOpen(true); }, + close: () => { setIsOpen(false); }, + selectedOption, + }); } // If a render prop is not used, try to attach it to the element directly. @@ -114,8 +152,8 @@ export const DropdownMenuProvider = Object.assign( return children; }; - const handleSelect = React.useCallback((optionKey: ListBox.ItemKey) => { - setSelectedOption(optionKey); + const handleSelect = React.useCallback((itemDetails: null | ListBox.ItemDetails) => { + setSelectedOption(itemDetails); // Note: add a slight delay before closing, to make it less jarring (and allow "select" animations to run) window.setTimeout(() => { @@ -123,25 +161,45 @@ export const DropdownMenuProvider = Object.assign( }, 150); }, [setIsOpen]); - return ( - <> - {renderAnchor()} - + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleSelect(selectedOption); + } + }, [selectedOption, handleSelect]); + + const renderDropdown = () => { + const floatingProps = getFloatingProps({ + popover: 'manual', + style: floatingStyles, + ...propsRest, + className: cx(cl['bk-dropdown-menu-provider__list-box'], propsRest.className), + onKeyDown: mergeCallbacks([handleKeyDown, propsRest.onKeyDown]), + }); + + return ( >(listBoxRef, refs.setFloating, propsRest.ref)} + {...floatingProps} + ref={mergeRefs>( + listBoxRef, + refs.setFloating, + floatingProps.ref as React.Ref>, + propsRest.ref, + )} + id={listBoxId} + selected={selectedOption?.itemKey ?? null} + onSelect={mergeCallbacks([handleSelect, onSelect])} data-placement={placementEffective} > {items} + ); + }; + + return ( + <> + {renderAnchor()} + {shouldMountDropdown && renderDropdown()} ); }, diff --git a/src/components/overlays/Tooltip/TooltipProvider.tsx b/src/components/overlays/Tooltip/TooltipProvider.tsx index 93d7205e..ed708b93 100644 --- a/src/components/overlays/Tooltip/TooltipProvider.tsx +++ b/src/components/overlays/Tooltip/TooltipProvider.tsx @@ -68,7 +68,9 @@ export const TooltipProvider = (props: TooltipProviderProps) => { getFloatingProps, placement: activePlacement, } = useFloatingElement({ + role: 'tooltip', action: 'hover', + keyboardInteractions: 'default', placement, offset: 14, enablePreciseTracking, diff --git a/src/components/util/input_util.tsx b/src/components/util/input_util.tsx new file mode 100644 index 00000000..4f99897c --- /dev/null +++ b/src/components/util/input_util.tsx @@ -0,0 +1,82 @@ + +type BaseInputProps = React.ComponentProps<'input'>; + +// We want props to be applied to the wrapper `
      ` by default, since most common props (e.g. ID, class name, event +// handlers like `onClick`) make sense on the outer element. We offer `inputProps` as a way to pass props to the inner +// `` element. However, additionally we allow the following input-specific props as a convenience. + +// +// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#attributes +export const inputSpecificPropKeys = { + accept: true, + alt: true, + autoCapitalize: true, + autoComplete: true, + capture: true, + checked: true, + disabled: true, + form: true, + formAction: true, + formEncType: true, + formMethod: true, + formNoValidate: true, + formTarget: true, + height: true, + list: true, + max: true, + maxLength: true, + min: true, + minLength: true, + multiple: true, + name: true, + pattern: true, + placeholder: true, + readOnly: true, + required: true, + size: true, + src: true, + step: true, + type: true, + value: true, + width: true, + onInput: true, + onInputCapture: true, + onChange: true, + onChangeCapture: true, +} as const satisfies Partial<{ [key in keyof React.InputHTMLAttributes]: true }>; +export type InputSpecificPropKeys = keyof typeof inputSpecificPropKeys; + +export type InputSpecificProps = Partial>; + + +type ExtractedProps

      = { + containerProps: Omit, + inputProps: InputSpecificProps, +}; +/** Split props into container-specific and input-specific. */ +export const extractInputSpecificProps =

      (props: P): ExtractedProps

      => { + const containerProps: Record = { ...props }; + const inputProps: InputSpecificProps = {}; + + for (const key of Object.keys(inputSpecificPropKeys)) { + if (key in props) { + // @ts-ignore + inputProps[key as keyof InputSpecificProps] = props[key as keyof P]; + delete containerProps[key]; + } + } + + // All ARIA props should be on the ``, since it is the element that receives the focus. + for (const key of Object.keys(props)) { + if (key === 'role' || key.startsWith('aria-')) { + // @ts-ignore + inputProps[key as keyof InputSpecificProps] = props[key as keyof P]; + delete containerProps[key]; + } + } + + return { + containerProps: containerProps as Omit, + inputProps: inputProps as InputSpecificProps, + }; +}; diff --git a/src/components/util/overlays/floating-ui/useFloatingElement.tsx b/src/components/util/overlays/floating-ui/useFloatingElement.tsx index f25070e9..c714d91c 100644 --- a/src/components/util/overlays/floating-ui/useFloatingElement.tsx +++ b/src/components/util/overlays/floating-ui/useFloatingElement.tsx @@ -15,6 +15,7 @@ import { flip, arrow, type ElementProps, + type UseRoleProps, type UseFloatingOptions, type FlipOptions, type FloatingContext, @@ -33,16 +34,43 @@ import { export type { Placement }; +// Sync the `isOpen` state with browser `popover` state +const usePopover = (context: FloatingContext): ElementProps => { + return { + floating: { + ref: floatingElement => { + if (!floatingElement) { return; } + + const isPopoverShown = floatingElement.matches(':popover-open'); + if (context.open && !isPopoverShown) { + floatingElement.showPopover(); + } else if (!context.open && isPopoverShown) { + floatingElement.hidePopover(); + } + }, + }, + }; +}; + export type UseFloatingElementOptions = { - floatingUiOptions?: UseFloatingOptions, - floatingUiFlipOptions?: FlipOptions, - floatingUiInteractions?: (context: FloatingContext) => Array, + floatingUiOptions?: undefined | UseFloatingOptions, + floatingUiFlipOptions?: undefined | FlipOptions, + floatingUiInteractions?: undefined | ((context: FloatingContext) => Array), + role?: undefined | UseRoleProps['role'], action?: undefined | 'hover' | 'click', placement?: undefined | Placement, offset?: undefined | number, - enablePreciseTracking?: boolean, // Enable more precise tracking of the anchor, at the cost of performance + /** + * The kind of keyboard interactions to include: + * - 'none': No keyboard interactions set. + * - 'form-control': Appropriate keyboard interactions for a form control (e.g. Enter should trigger submit). + * - 'default': Acts as a menu button [1] (e.g. Enter will activate the popover). + * [1] https://www.w3.org/WAI/ARIA/apg/patterns/menu-button + */ + keyboardInteractions?: undefined | 'none' | 'form-control' | 'default', + enablePreciseTracking?: undefined | boolean, // Enable more precise tracking of the anchor, at the cost of performance boundary?: undefined | Element, - arrowRef?: React.RefObject, // Reference to the arrow element, if any + arrowRef?: undefined | React.RefObject, // Reference to the arrow element, if any hasDelayGroup?: undefined | boolean, }; /** @@ -54,9 +82,11 @@ export const useFloatingElement = (options: UseFloatingElementOptions = {}) => { floatingUiOptions: options.floatingUiOptions ?? {}, floatingUiFlipOptions: options.floatingUiFlipOptions ?? {}, floatingUiInteractions: options.floatingUiInteractions ?? (() => []), + role: options.role, action: options.action ?? 'click', placement: options.placement ?? 'top', offset: options.offset ?? 0, + keyboardInteractions: options.keyboardInteractions ?? 'default', enablePreciseTracking: options.enablePreciseTracking ?? false, arrowRef: options.arrowRef ?? null, hasDelayGroup: options.hasDelayGroup ?? false, @@ -93,9 +123,20 @@ export const useFloatingElement = (options: UseFloatingElementOptions = {}) => { const [isOpen, setIsOpen] = React.useState(false); + const onOpenChange = React.useCallback['onOpenChange']>( + (isOpen, event, _reason) => { + const shouldIgnoreEnter = optionsWithDefaults.keyboardInteractions === 'form-control'; + if (shouldIgnoreEnter && event instanceof KeyboardEvent && event.key === 'Enter') { + return; + } + setIsOpen(isOpen); + }, + [optionsWithDefaults.keyboardInteractions], + ); + const { context, refs, placement, floatingStyles } = useFloating({ open: isOpen, - onOpenChange: setIsOpen, + onOpenChange, placement: optionsWithDefaults.placement, strategy: 'fixed', // Use `fixed` to contain within the viewport (as opposed to containing block with `absolute`) whileElementsMounted(referenceEl, floatingEl, update) { @@ -113,14 +154,20 @@ export const useFloatingElement = (options: UseFloatingElementOptions = {}) => { // Note: for `role="tooltip", no `aria-haspop` is necessary on the anchor because it is not interactive: // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-haspopup - const role = useRole(context, { role: 'tooltip' }); + const role = useRole(context, { + ...optionsWithDefaults.role ? { role: optionsWithDefaults.role } : {}, + }); const interactions: Array = [ role, + usePopover(context), ]; if (action === 'click') { - interactions.push(useClick(context, { toggle: true })); + interactions.push(useClick(context, { + toggle: true, + keyboardHandlers: optionsWithDefaults.keyboardInteractions === 'default', + })); interactions.push(useDismiss(context)); } else if (action === 'hover') { interactions.push(useFocus(context)); @@ -143,19 +190,6 @@ export const useFloatingElement = (options: UseFloatingElementOptions = {}) => { // Keep the tooltip mounted for a little while after close to allow exit animations to occur const { isMounted } = useTransitionStatus(context, { duration: { open: 0, close: 500 } }); - // Sync the `isOpen` state with browser `popover` state - React.useEffect(() => { - const floatingElement = refs.floating.current; - if (!floatingElement) { return; } - - const isPopoverShown = floatingElement.matches(':popover-open'); - if (isOpen && !isPopoverShown) { - floatingElement.showPopover(); - } else if (!isOpen && isPopoverShown) { - floatingElement.hidePopover(); - } - }, [isOpen, refs.floating]); - return { context, isOpen, diff --git a/src/layouts/AppLayout/Header/AccountSelector.tsx b/src/layouts/AppLayout/Header/AccountSelector.tsx index bcf14317..7207ca50 100644 --- a/src/layouts/AppLayout/Header/AccountSelector.tsx +++ b/src/layouts/AppLayout/Header/AccountSelector.tsx @@ -22,6 +22,7 @@ export type AccountSelectorProps = Omit, 'label' | /** The accounts list to be shown in the dropdown menu. */ accounts: React.ReactNode, + /** The selected account. To access the selected account, pass a render prop. */ children: (selectedAccount: null | ItemKey) => React.ReactNode, }; export const AccountSelector = Object.assign( diff --git a/src/layouts/AppLayout/Header/Header.module.scss b/src/layouts/AppLayout/Header/Header.module.scss index cc1df4c9..22797242 100644 --- a/src/layouts/AppLayout/Header/Header.module.scss +++ b/src/layouts/AppLayout/Header/Header.module.scss @@ -24,6 +24,7 @@ display: flex; flex-direction: row-reverse; + reading-flow: flex-visual; gap: bk.$spacing-7; .user-profile-trigger { diff --git a/src/util/hooks/useDebounce.ts b/src/util/hooks/useDebounce.ts index 396ee351..45382e3d 100644 --- a/src/util/hooks/useDebounce.ts +++ b/src/util/hooks/useDebounce.ts @@ -13,7 +13,19 @@ export const useDebounce = (value: S, delay: number /* ms */): UseDebounceRes const [debouncedValue, setDebouncedValue] = React.useState(value); const timeoutHandleRef = React.useRef(null); + // Whenever `value` changes, schedule an update after `delay` ms React.useEffect(() => { + if (delay === 0) { + setDebouncedValue(value); + + const timeoutHandle = timeoutHandleRef.current; + if (timeoutHandle) { + globalThis.clearTimeout(timeoutHandle); + } + + return; + } + timeoutHandleRef.current = globalThis.setTimeout(() => { setDebouncedValue(value); }, delay); @@ -26,6 +38,7 @@ export const useDebounce = (value: S, delay: number /* ms */): UseDebounceRes }; }, [value, delay]); + // Expose a way to force the debounced value manually const setDebouncedManually = React.useCallback((value: React.SetStateAction) => { setDebouncedValue(value); diff --git a/src/util/types.ts b/src/util/types.ts index 638f1e23..a09e6ff8 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -2,9 +2,11 @@ |* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of |* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -// TypeScript has `NonNullable` built in, but not `NonUndefined`. -// Note: when TS refines non-undefined, it produces `T & ({} | null)`. Do not define this type as -// `T extends undefined ? never : T`, because you cannot `T & ({} | null)` cannot be assigned to such a conditional. +/** + * TypeScript has `NonNullable` built in, but not `NonUndefined`. + * Note: when TS refines non-undefined, it produces `T & ({} | null)`. Do not define this type as + * `T extends undefined ? never : T`, because you cannot `T & ({} | null)` cannot be assigned to such a conditional. + */ // https://www.typescriptlang.org/play/?#code/LAKAxg9gdgzgLgAgGZQQXgQHgCoBoB8AFAIYBOA5gFwLYCU6+CA3qAmwgJZIKFwCeABwCmEbmXLo0GAOQBXKABMhSDlCELp9FiHa6EpIXFmkoAblbsAvhbbjzIS-aA export type NonUndefined = T & ({} | null); @@ -14,5 +16,5 @@ export const assertUnreachable = (value: never, message?: string): never => { throw new Error(message ?? `Unexpected case`); }; -// Given a type `T`, the keys `K` should be required, and everything else becomes optional. +/** Given a type `T`, the keys `K` should be required, and everything else becomes optional. */ export type RequireOnly = Pick, K> & Partial; diff --git a/stylelint.config.mjs b/stylelint.config.mjs index 39893692..026e7fd4 100644 --- a/stylelint.config.mjs +++ b/stylelint.config.mjs @@ -87,7 +87,7 @@ export default { 'scss/no-global-function-names': null, // CSS extensions (e.g. CSS modules, or future CSS) - //'property-no-unknown': [true, { ignoreProperties: [] }], + 'property-no-unknown': [true, { ignoreProperties: ['reading-flow', 'reading-order'] }], //'scss/at-rule-no-unknown': [true, { ignoreAtRules: [] }], 'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global', 'local'] }], 'selector-pseudo-element-no-unknown': [true, { ignorePseudoElements: [] }], From de348f7d20251c84aa1aa52fa44a3c692eb3bc38 Mon Sep 17 00:00:00 2001 From: mkrause Date: Wed, 30 Apr 2025 14:00:08 +0200 Subject: [PATCH 4/8] Work on migrating DropdownMenu/Select to use ListBox. --- .../controls/ListBoxLazy/ListBoxLazy.tsx | 26 ++++++++++++++++++- .../DropdownMenuProvider.stories.tsx | 7 +++-- .../DropdownMenu/DropdownMenuProvider.tsx | 1 + src/layouts/AppLayout/AppLayout.stories.tsx | 2 +- .../Header/AccountSelector.stories.tsx | 2 +- .../AppLayout/Header/AccountSelector.tsx | 4 +-- 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/components/forms/controls/ListBoxLazy/ListBoxLazy.tsx b/src/components/forms/controls/ListBoxLazy/ListBoxLazy.tsx index 55569c66..85263e93 100644 --- a/src/components/forms/controls/ListBoxLazy/ListBoxLazy.tsx +++ b/src/components/forms/controls/ListBoxLazy/ListBoxLazy.tsx @@ -13,7 +13,12 @@ import { } from '@tanstack/react-virtual'; import { Spinner } from '../../../graphics/Spinner/Spinner.tsx'; -import { type VirtualItemKeys, VirtualItemKeysUtil, useListBoxSelector } from '../ListBox/ListBoxStore.tsx'; +import { + type ItemKey, + type VirtualItemKeys, + VirtualItemKeysUtil, + useListBoxSelector, +} from '../ListBox/ListBoxStore.tsx'; import { ListBox } from '../ListBox/ListBox.tsx'; import cl from './ListBoxLazy.module.scss'; @@ -236,6 +241,24 @@ export const ListBoxLazy = (props: ListBoxLazyProps) => { renderItem, }; + const formatItemLabel = React.useCallback((itemKey: ItemKey) => { + const virtualItem: VirtualItem = { + key: itemKey, + index: 0, + start: 0, + end: 0, + size: 0, + lane: 0, + }; + const label = renderItem(virtualItem); + + if (typeof label !== 'string') { + console.warn(`Unable to render item to string label: '${itemKey}', found type '${typeof label}'`); + } + + return typeof label === 'string' ? label : ''; + }, [renderItem]); + return ( { propsRest.className, )} virtualItemKeys={virtualItemKeys} + formatItemLabel={formatItemLabel} > diff --git a/src/components/overlays/DropdownMenu/DropdownMenuProvider.stories.tsx b/src/components/overlays/DropdownMenu/DropdownMenuProvider.stories.tsx index 1fc857f7..22c10bb1 100644 --- a/src/components/overlays/DropdownMenu/DropdownMenuProvider.stories.tsx +++ b/src/components/overlays/DropdownMenu/DropdownMenuProvider.stories.tsx @@ -33,7 +33,7 @@ export const Standard: Story = { args: { children: ({ props, selectedOption }) => ( ), items: ( @@ -56,7 +56,10 @@ export const WithPlacement: Story = { placement: 'right', children: ({ props, selectedOption }) => ( ), items: ( diff --git a/src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx b/src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx index e1e46bf0..b1f2e22c 100644 --- a/src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx +++ b/src/components/overlays/DropdownMenu/DropdownMenuProvider.tsx @@ -17,6 +17,7 @@ import * as ListBox from '../../forms/controls/ListBox/ListBox.tsx'; import cl from './DropdownMenuProvider.module.scss'; +export type ItemDetails = ListBox.ItemDetails; export type ItemKey = ListBox.ItemKey; type ListBoxProps = ComponentProps; diff --git a/src/layouts/AppLayout/AppLayout.stories.tsx b/src/layouts/AppLayout/AppLayout.stories.tsx index 4aabef5f..87947c9e 100644 --- a/src/layouts/AppLayout/AppLayout.stories.tsx +++ b/src/layouts/AppLayout/AppLayout.stories.tsx @@ -78,7 +78,7 @@ export const Standard: Story = { } > - {selectedAccount => selectedAccount === null ? 'Accounts' : selectedAccount.replace(/^account_/, '')} + {selectedAccount => selectedAccount === null ? 'Accounts' : selectedAccount.label} {['Identity & Access Management', 'Key Insight', 'Data Security Manager'].map(name => diff --git a/src/layouts/AppLayout/Header/AccountSelector.stories.tsx b/src/layouts/AppLayout/Header/AccountSelector.stories.tsx index 3245f428..eb9dd1ff 100644 --- a/src/layouts/AppLayout/Header/AccountSelector.stories.tsx +++ b/src/layouts/AppLayout/Header/AccountSelector.stories.tsx @@ -41,6 +41,6 @@ export const AccountSelectorStandard: Story = { ), - children: selectedAccount => selectedAccount === null ? 'Accounts' : selectedAccount.replace(/^account_/, '') + children: selectedAccount => selectedAccount === null ? 'Accounts' : selectedAccount.label }, }; diff --git a/src/layouts/AppLayout/Header/AccountSelector.tsx b/src/layouts/AppLayout/Header/AccountSelector.tsx index 7207ca50..5a4bd47a 100644 --- a/src/layouts/AppLayout/Header/AccountSelector.tsx +++ b/src/layouts/AppLayout/Header/AccountSelector.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { Icon } from '../../../components/graphics/Icon/Icon.tsx'; import { Button } from '../../../components/actions/Button/Button.tsx'; -import { type ItemKey, DropdownMenuProvider } from '../../../components/overlays/DropdownMenu/DropdownMenuProvider.tsx'; +import { type ItemDetails, DropdownMenuProvider } from '../../../components/overlays/DropdownMenu/DropdownMenuProvider.tsx'; import cl from './AccountSelector.module.scss'; @@ -23,7 +23,7 @@ export type AccountSelectorProps = Omit, 'label' | accounts: React.ReactNode, /** The selected account. To access the selected account, pass a render prop. */ - children: (selectedAccount: null | ItemKey) => React.ReactNode, + children: (selectedAccount: null | ItemDetails) => React.ReactNode, }; export const AccountSelector = Object.assign( (props: AccountSelectorProps) => { From c8b132f2aa8d6ea227d05d4556a8c7bb4fee11b8 Mon Sep 17 00:00:00 2001 From: mkrause Date: Mon, 5 May 2025 17:33:05 +0200 Subject: [PATCH 5/8] ListBox: update styling. --- scripts/import.ts | 4 + src/components/actions/Button/Button.tsx | 2 +- .../containers/Banner/Banner.module.scss | 17 ++-- .../containers/Dialog/Dialog.module.scss | 10 +-- .../containers/Dialog/Dialog.stories.tsx | 1 + src/components/containers/Dialog/Dialog.tsx | 2 +- .../controls/ListBox/ListBox.module.scss | 75 +++++++++------- .../controls/ListBox/ListBox.stories.tsx | 44 +++++++++- .../forms/controls/ListBox/ListBox.tsx | 85 ++++++++++++++++--- .../forms/controls/ListBox/ListBoxStore.tsx | 25 +++++- src/components/graphics/Icon/Icon.module.scss | 2 +- src/styling/defs.scss | 3 +- src/styling/features/layout.scss | 24 +++++- src/styling/features/scroll.scss | 18 ++++ src/styling/generated/colors_semantic.scss | 12 +++ src/styling/global/accessibility.scss | 8 +- src/util/componentUtil.ts | 2 +- .../storybook/LayoutDecorator.module.scss | 8 +- stylelint.config.mjs | 2 + 19 files changed, 266 insertions(+), 78 deletions(-) create mode 100644 src/styling/features/scroll.scss diff --git a/scripts/import.ts b/scripts/import.ts index dd2ed1a3..fb1ac7c4 100755 --- a/scripts/import.ts +++ b/scripts/import.ts @@ -216,6 +216,10 @@ const runImportColorsSemantic = async (args: ScriptArgs) => { throw new Error(`Should not happen`); } + if (tokenValue.match(/#[a-z0-9]{3,6}/i)) { + throw new Error(`Found semantic color token defined using a hardcoded hex color: '${tokenName}'`); + } + // Replace references to primitive color tokens with their Sass variable equivalent const color = tokenValue.replaceAll(/var\(--(.+?)-(\d+)\)/g, '\$color-$1-$2'); diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index 88755359..38701154 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -147,7 +147,7 @@ export const Button = (props: ButtonProps) => { return (