From 4107319c7b63dc4b476de8f8fae994a16c75a84a Mon Sep 17 00:00:00 2001 From: Gianluca La Manna Date: Tue, 24 Oct 2023 12:56:48 +0200 Subject: [PATCH 01/13] WIP: add SfTextarea --- apps/docs/utils/components.ts | 2 +- apps/docs/utils/hooks.ts | 2 +- .../src/routes/examples/SfTextarea/index.tsx | 201 ++++++++++++++++++ .../focusVisibleManager.ts | 180 ++++++++++++++++ .../shared/focusVisibleManager/index.ts | 2 + .../shared/focusVisibleManager/types.ts | 10 + .../shared/utils/browser.ts | 38 ++++ .../src/components/SfTextarea/SfTextarea.tsx | 42 ++++ .../src/components/SfTextarea/index.ts | 3 + .../src/components/SfTextarea/types.ts | 17 ++ .../src/components/index.ts | 1 + .../src/shared/hooks/useFocusVisible/index.ts | 1 + .../src/shared/hooks/useFocusVisible/types.ts | 8 + .../hooks/useFocusVisible/useFocusVisible.ts | 33 +++ 14 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 apps/website/src/routes/examples/SfTextarea/index.tsx create mode 100644 packages/qwik-storefront-ui/shared/focusVisibleManager/focusVisibleManager.ts create mode 100644 packages/qwik-storefront-ui/shared/focusVisibleManager/index.ts create mode 100644 packages/qwik-storefront-ui/shared/focusVisibleManager/types.ts create mode 100644 packages/qwik-storefront-ui/shared/utils/browser.ts create mode 100644 packages/qwik-storefront-ui/src/components/SfTextarea/SfTextarea.tsx create mode 100644 packages/qwik-storefront-ui/src/components/SfTextarea/index.ts create mode 100644 packages/qwik-storefront-ui/src/components/SfTextarea/types.ts create mode 100644 packages/qwik-storefront-ui/src/shared/hooks/useFocusVisible/index.ts create mode 100644 packages/qwik-storefront-ui/src/shared/hooks/useFocusVisible/types.ts create mode 100644 packages/qwik-storefront-ui/src/shared/hooks/useFocusVisible/useFocusVisible.ts diff --git a/apps/docs/utils/components.ts b/apps/docs/utils/components.ts index 080f249..d10836a 100644 --- a/apps/docs/utils/components.ts +++ b/apps/docs/utils/components.ts @@ -23,7 +23,7 @@ export const components = { // 'SfScrollable', // 'SfSelect', 'SfSwitch', - // 'SfTextarea', + 'SfTextarea', // 'SfThumbnail', // 'SfTooltip', ], diff --git a/apps/docs/utils/hooks.ts b/apps/docs/utils/hooks.ts index 4479733..e513f29 100644 --- a/apps/docs/utils/hooks.ts +++ b/apps/docs/utils/hooks.ts @@ -1,7 +1,7 @@ export const hooks = [ // 'useDisclosure', // 'useDropdown', - // 'useFocusVisible', + 'useFocusVisible', // 'usePagination', // 'usePopover', // 'useScrollable', diff --git a/apps/website/src/routes/examples/SfTextarea/index.tsx b/apps/website/src/routes/examples/SfTextarea/index.tsx new file mode 100644 index 0000000..37bf42c --- /dev/null +++ b/apps/website/src/routes/examples/SfTextarea/index.tsx @@ -0,0 +1,201 @@ +import { + $, + QwikChangeEvent, + component$, + useContext, + useTask$, +} from '@builder.io/qwik'; +import { SfTextarea, SfTextareaSize } from 'qwik-storefront-ui'; +import { ComponentExample } from '../../../components/utils/ComponentExample'; +import { ControlsType } from '../../../components/utils/types'; +import { EXAMPLES_STATE } from '../layout'; + +export default component$(() => { + const examplesState = useContext(EXAMPLES_STATE); + + useTask$(() => { + examplesState.data = { + controls: [ + { + type: 'select', + modelName: 'size', + propDefaultValue: SfTextareaSize.base, + propType: 'SfInputSize', + options: Object.keys(SfTextareaSize), + isRequired: false, + }, + { + type: 'text', + propType: 'string', + modelName: 'label', + isRequired: false, + }, + { + type: 'text', + propType: 'string', + modelName: 'placeholder', + isRequired: false, + }, + { + type: 'text', + propType: 'string', + modelName: 'helpText', + isRequired: false, + }, + { + type: 'text', + propType: 'string', + modelName: 'requiredText', + isRequired: false, + }, + { + type: 'text', + propType: 'string', + modelName: 'errorText', + isRequired: false, + }, + { + type: 'text', + propType: 'number', + modelName: 'characterLimit', + isRequired: false, + }, + { + type: 'boolean', + propType: 'boolean', + modelName: 'disabled', + isRequired: false, + }, + { + type: 'boolean', + propType: 'boolean', + modelName: 'required', + isRequired: false, + }, + { + type: 'boolean', + propType: 'boolean', + modelName: 'invalid', + isRequired: false, + }, + { + type: 'boolean', + propType: 'boolean', + modelName: 'readonly', + isRequired: false, + }, + ] satisfies ControlsType, + state: { + size: SfTextareaSize.base, + disabled: false, + required: false, + invalid: false, + readonly: undefined, + placeholder: 'Write something about yourself', + helpText: 'Do not include personal or financial information.', + requiredText: 'Required text', + errorText: 'Error message', + label: 'Description', + characterLimit: 12, + value: '', + }, + }; + }); + + const onChange = $((event: QwikChangeEvent) => { + examplesState.data.state = { + ...examplesState.data.state, + value: event.target.value, + }; + }); + + const isAboveLimit = examplesState.data.state.characterLimit + ? examplesState.data.state.value.length > + examplesState.data.state.characterLimit + : false; + const charsCount = examplesState.data.state.characterLimit + ? examplesState.data.state.characterLimit - + examplesState.data.state.value.length + : null; + + const getCharacterLimitClass = () => + isAboveLimit ? 'text-negative-700 font-medium' : 'text-neutral-500'; + + return ( + + +
+
+ {examplesState.data.state.invalid && + !examplesState.data.state.disabled && ( +

+ {examplesState.data.state.errorText} +

+ )} + {examplesState.data.state.helpText && ( +

+ {examplesState.data.state.helpText} +

+ )} + {examplesState.data.state.requiredText && + examplesState.data.state.required ? ( +

+ {examplesState.data.state.requiredText} +

+ ) : null} +
+ {examplesState.data.state.characterLimit && + !examplesState.data.state.readonly ? ( +

+ {charsCount} +

+ ) : null} +
+
+ ); +}); diff --git a/packages/qwik-storefront-ui/shared/focusVisibleManager/focusVisibleManager.ts b/packages/qwik-storefront-ui/shared/focusVisibleManager/focusVisibleManager.ts new file mode 100644 index 0000000..ee5deee --- /dev/null +++ b/packages/qwik-storefront-ui/shared/focusVisibleManager/focusVisibleManager.ts @@ -0,0 +1,180 @@ +import { isAndroid, isMac } from '../utils/browser'; +import { FocusHandler, FocusHandlerEvent, FocusModality } from './types'; + +// This code has been implemented based on @react-aria useFocusVisible +// https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/interactions/src/useFocusVisible.ts + +// Keyboards, Assistive Technologies, and element.click() all produce a "virtual" +// click event. This is a method of inferring such clicks. Every browser except +// IE 11 only sets a zero value of "detail" for click events that are "virtual". +// However, IE 11 uses a zero value for all click events. For IE 11 we rely on +// the quirk that it produces click events that are of type PointerEvent, and +// where only the "virtual" click lacks a pointerType field. + +function isVirtualClick(event: MouseEvent | PointerEvent): boolean { + // JAWS/NVDA with Firefox. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((event as any).mozInputSource === 0 && event.isTrusted) { + return true; + } + + // Android TalkBack's detail value varies depending on the event listener providing the event so we have specific logic here instead + // If pointerType is defined, event is from a click listener. For events from mousedown listener, detail === 0 is a sufficient check + // to detect TalkBack virtual clicks. + if (isAndroid && (event as PointerEvent).pointerType) { + return event.type === 'click' && event.buttons === 1; + } + + return event.detail === 0 && !(event as PointerEvent).pointerType; +} + +/** + * Helper function to determine if a KeyboardEvent is unmodified and could make keyboard focus styles visible. + */ +function isValidKey(e: KeyboardEvent) { + // Control and Shift keys trigger when navigating back to the tab with keyboard. + return !( + e.metaKey || + (!isMac && e.altKey) || + e.ctrlKey || + e.key === 'Control' || + e.key === 'Shift' || + e.key === 'Meta' + ); +} + +export const focusVisibleManager = () => { + let currentModality: FocusModality | null = null; + const changeHandlers = new Set(); + let hasSetupGlobalListeners = false; + let hasEventBeforeFocus = false; + let hasBlurredWindowRecently = false; + + const isFocusVisible = () => currentModality !== 'pointer'; + + const triggerChangeHandlers = ( + modality: FocusModality, + e: FocusHandlerEvent + ) => { + // eslint-disable-next-line no-restricted-syntax + for (const handler of changeHandlers) { + handler(modality, e); + } + }; + + const handleKeyboardEvent = (e: KeyboardEvent) => { + hasEventBeforeFocus = true; + if (isValidKey(e)) { + currentModality = 'keyboard'; + triggerChangeHandlers('keyboard', e); + } + }; + + const handlePointerEvent = (e: PointerEvent | MouseEvent) => { + currentModality = 'pointer'; + if (e.type === 'mousedown' || e.type === 'pointerdown') { + hasEventBeforeFocus = true; + triggerChangeHandlers('pointer', e); + } + }; + + const handleClickEvent = (e: MouseEvent) => { + if (isVirtualClick(e)) { + hasEventBeforeFocus = true; + currentModality = 'virtual'; + } + }; + + const handleFocusEvent = (e: FocusEvent) => { + // Firefox fires two extra focus events when the user first clicks into an iframe: + // first on the window, then on the document. We ignore these events so they don't + // cause keyboard focus rings to appear. + if (e.target === window || e.target === document) { + return; + } + + // If a focus event occurs without a preceding keyboard or pointer event, switch to virtual modality. + // This occurs, for example, when navigating a form with the next/previous buttons on iOS. + if (!hasEventBeforeFocus && !hasBlurredWindowRecently) { + currentModality = 'virtual'; + triggerChangeHandlers('virtual', e); + } + + hasEventBeforeFocus = false; + hasBlurredWindowRecently = false; + }; + + const handleWindowBlur = () => { + // When the window is blurred, reset state. This is necessary when tabbing out of the window, + // for example, since a subsequent focus event won't be fired. + hasEventBeforeFocus = false; + hasBlurredWindowRecently = true; + }; + + /** + * Setup global event listeners to control when keyboard focus style should be visible. + */ + const setupGlobalFocusEvents = () => { + if (typeof window === 'undefined' || hasSetupGlobalListeners) { + return; + } + + // Programmatic focus() calls shouldn't affect the current input modality. + // However, we need to detect other cases when a focus event occurs without + // a preceding user event (e.g. screen reader focus). Overriding the focus + // method on HTMLElement.prototype is a bit hacky, but works. + // eslint-disable-next-line prefer-destructuring + const focus = HTMLElement.prototype.focus; + // eslint-disable-next-line func-names + HTMLElement.prototype.focus = function () { + hasEventBeforeFocus = true; + // eslint-disable-next-line prefer-rest-params, @typescript-eslint/no-explicit-any + focus.apply(this, arguments as any); + }; + + document.addEventListener('keydown', handleKeyboardEvent, true); + document.addEventListener('keyup', handleKeyboardEvent, true); + document.addEventListener('click', handleClickEvent, true); + + // Register focus events on the window so they are sure to happen + // before React's event listeners (registered on the document). + window.addEventListener('focus', handleFocusEvent, true); + window.addEventListener('blur', handleWindowBlur, false); + + if (typeof PointerEvent !== 'undefined') { + document.addEventListener('pointerdown', handlePointerEvent, true); + document.addEventListener('pointermove', handlePointerEvent, true); + document.addEventListener('pointerup', handlePointerEvent, true); + } else { + document.addEventListener('mousedown', handlePointerEvent, true); + document.addEventListener('mousemove', handlePointerEvent, true); + document.addEventListener('mouseup', handlePointerEvent, true); + } + + hasSetupGlobalListeners = true; + }; + + /** + * If this is attached to text input component, return if the event is a focus event (Tab/Escape keys pressed) so that + * focus visible style can be properly set. + */ + const isKeyboardFocusEvent = ( + isTextInput: boolean | undefined, + modality: FocusModality, + e: FocusHandlerEvent + ) => + !( + isTextInput && + modality === 'keyboard' && + e instanceof KeyboardEvent && + // Only Tab or Esc keys will make focus visible on text input elements + !['Tab', 'Escape'].includes(e.key) + ); + + return { + isFocusVisible, + changeHandlers, + setupGlobalFocusEvents, + isKeyboardFocusEvent, + }; +}; diff --git a/packages/qwik-storefront-ui/shared/focusVisibleManager/index.ts b/packages/qwik-storefront-ui/shared/focusVisibleManager/index.ts new file mode 100644 index 0000000..01ea5c9 --- /dev/null +++ b/packages/qwik-storefront-ui/shared/focusVisibleManager/index.ts @@ -0,0 +1,2 @@ +export * from './focusVisibleManager'; +export * from './types'; diff --git a/packages/qwik-storefront-ui/shared/focusVisibleManager/types.ts b/packages/qwik-storefront-ui/shared/focusVisibleManager/types.ts new file mode 100644 index 0000000..a3c5a8f --- /dev/null +++ b/packages/qwik-storefront-ui/shared/focusVisibleManager/types.ts @@ -0,0 +1,10 @@ +export type FocusModality = 'keyboard' | 'pointer' | 'virtual'; +export type FocusHandlerEvent = + | PointerEvent + | MouseEvent + | KeyboardEvent + | FocusEvent; +export type FocusHandler = ( + modality: FocusModality, + e: FocusHandlerEvent +) => void; diff --git a/packages/qwik-storefront-ui/shared/utils/browser.ts b/packages/qwik-storefront-ui/shared/utils/browser.ts new file mode 100644 index 0000000..3b8afbb --- /dev/null +++ b/packages/qwik-storefront-ui/shared/utils/browser.ts @@ -0,0 +1,38 @@ +export const isBrowser = typeof window !== 'undefined'; +export const isReduceMotionEnabled = + isBrowser && window?.matchMedia('(prefers-reduced-motion: reduce)').matches; + +declare global { + interface Navigator { + userAgentData?: { + platform: string; + brands: { + brand: string; + version: string; + }[]; + }; + } +} + +function testPlatform(re: RegExp) { + return typeof window !== 'undefined' && window.navigator != null + ? re.test( + window.navigator['userAgentData']?.platform || window.navigator.platform + ) + : false; +} + +function testUserAgent(re: RegExp) { + if (typeof window === 'undefined' || window.navigator == null) { + return false; + } + return ( + window.navigator['userAgentData']?.brands.some( + (brand: { brand: string; version: string }) => re.test(brand.brand) + ) || re.test(window.navigator.userAgent) + ); +} + +export const isMac = testPlatform(/^Mac/i); +export const isAndroid = testUserAgent(/Android/i); +export const isSafari = testUserAgent(/^((?!chrome|android).)*safari/i); diff --git a/packages/qwik-storefront-ui/src/components/SfTextarea/SfTextarea.tsx b/packages/qwik-storefront-ui/src/components/SfTextarea/SfTextarea.tsx new file mode 100644 index 0000000..c0a79a7 --- /dev/null +++ b/packages/qwik-storefront-ui/src/components/SfTextarea/SfTextarea.tsx @@ -0,0 +1,42 @@ +import { component$, useSignal } from '@builder.io/qwik'; +import { useFocusVisible } from '../../shared/hooks/useFocusVisible'; +import { SfTextareaSize, type SfTextareaProps } from './types'; + +const sizeClasses = { + [SfTextareaSize.sm]: 'h-[56px] py-[6px] pl-4 pr-3', + [SfTextareaSize.base]: 'h-[64px] py-2 pl-4 pr-3', + [SfTextareaSize.lg]: 'h-[72px], p-3 pl-4', +}; + +export const SfTextarea = component$( + ({ + size = SfTextareaSize.base, + invalid = false, + className, + ...attributes + }) => { + const ref = useSignal(); + const { isFocusVisible } = useFocusVisible({ isTextInput: true }); + + console.log('PLACEHOLDER', attributes); + //console.log('DESCRIPTION', examplesState.data.state.description) + + return ( +