diff --git a/apps/website/src/_state/component-statuses.ts b/apps/website/src/_state/component-statuses.ts index 52a244a25..903a06c33 100644 --- a/apps/website/src/_state/component-statuses.ts +++ b/apps/website/src/_state/component-statuses.ts @@ -36,16 +36,16 @@ export const componentsStatuses: ComponentKitsStatuses = { Tabs: 'Planned', Toast: 'Planned', Toggle: 'Planned', - Tooltip: 'Planned', + Tooltip: 'Planned' }, headless: { - Accordion: 'Planned', + Accordion: 'Ready', Autocomplete: 'Draft', Carousel: 'Planned', Popover: 'Planned', Select: 'Draft', Tabs: 'Ready', Toggle: 'Planned', - Tooltip: 'Planned', - }, + Tooltip: 'Planned' + } }; diff --git a/apps/website/src/routes/docs/_components/anatomy-table/anatomy-table.tsx b/apps/website/src/routes/docs/_components/anatomy-table/anatomy-table.tsx index 526a51ce7..41d70a70e 100644 --- a/apps/website/src/routes/docs/_components/anatomy-table/anatomy-table.tsx +++ b/apps/website/src/routes/docs/_components/anatomy-table/anatomy-table.tsx @@ -24,7 +24,7 @@ export const AnatomyTable = component$(({ propDescriptors }: AnatomyTableProps) {propDescriptors?.map((propDescriptor) => { return ( - + {propDescriptor.name} diff --git a/apps/website/src/routes/docs/headless/(components)/accordion/examples.tsx b/apps/website/src/routes/docs/headless/(components)/accordion/examples.tsx index 609ec9e58..6946e9f13 100644 --- a/apps/website/src/routes/docs/headless/(components)/accordion/examples.tsx +++ b/apps/website/src/routes/docs/headless/(components)/accordion/examples.tsx @@ -20,6 +20,7 @@ export const HeroAccordion = component$(() => {
@@ -589,63 +590,100 @@ export const OnFocusIndexChange = component$(() => { ); }); -export const DynamicAccordion = component$(() => { - const itemStore = useStore([1, 2]); +interface DynamicAccordionProps { + itemIndexToDelete?: number; + itemIndexToAdd?: number; + itemsLength: number; +} - return ( - -
- - {itemStore.map((itemNumber, index) => { - return ( - - - Trigger {itemNumber} - -

- + -

-
-
- - Content {itemNumber} - -
- ); - })} -
+export const DynamicAccordion = component$( + ({ itemsLength = 3 }: DynamicAccordionProps) => { + const itemIndexToAdd = useSignal('0'); + const itemIndexToDelete = useSignal('0'); + + // start off with some items + const items = []; + const newItem = { label: 'New Item', id: Math.random() }; + + for (let i = 0; i < itemsLength; i++) { + items.push({ + label: `Original Item ${i + 1}`, + id: Math.random() + }); + } + + const itemStore = useStore<{ label: string; id: number }[]>(items); + + return ( + +
+
+ -
- + +
- + + {itemStore.map(({ label, id }, index) => { + return ( + + + + {label} + + + + index: {index} + + + ); + })} + +
+ + +
-
-
- -
-
- ); -}); +
+ +
+ + ); + } +); export function SVG(props: QwikIntrinsicElements['svg'], key: string) { return ( diff --git a/apps/website/src/routes/docs/headless/(components)/accordion/index.mdx b/apps/website/src/routes/docs/headless/(components)/accordion/index.mdx index 7ed59248d..1d850790e 100644 --- a/apps/website/src/routes/docs/headless/(components)/accordion/index.mdx +++ b/apps/website/src/routes/docs/headless/(components)/accordion/index.mdx @@ -583,55 +583,90 @@ By default, when using the `AccordionHeader` component, it's rendered as an `h3` ```tsx - - {itemStore.map((itemNumber, index) => { + export const DynamicAccordion = component$( + ({ itemsLength = 3}: DynamicAccordionProps) => { + + const itemIndexToAdd = useSignal('0'); + const itemIndexToDelete = useSignal('0'); + + // start off with some items + const items = []; + const newItem = { label: 'New Item', id: Math.random() }; + + for(let i = 0; i < itemsLength; i++) { + items.push({ + label: `Original Item ${i + 1}`, + id: Math.random() + }); + } + + const itemStore = useStore<{ label: string, id: number }[]>(items); + + return ( +
+ + + +
+ + + {itemStore.map(({label, id}, index) => { return ( - - Trigger {itemNumber} - -

- + -

-
-
- - Content {itemNumber} - + + + + {label} + + + index: {index} - ) + ); })}
-
-
- + )} + ); ```
You can embrace reactivity, using signals, stores, and however else you'd like to use the Accordion with dynamic behavior. -When an Accordion Item is removed, a **Visible Task** runs that will clean up the DOM node in the browser, ensuring that you stay clear of race condition or memory leak issues. +When an Accordion Item is removed, a [Visible Task](https://qwik.builder.io/docs/components/tasks/#usevisibletask) runs that will clean up the DOM node in the browser, ensuring that you stay clear of race condition or memory leak issues. + +> You can add or remove something at any index and the focus order will adhere to the DOM hierarchy! + +
+ If you'd prefer to add your own id to the Accordion Item with dynamic behavior, you can add the `id` prop to the Accordion Item. This can be useful when you'd like the id value to be sync with your custom logic. +
+ +By default, the Accordion Item has a locally scoped id with Qwik's `useId` hook. All children elements will be prefixed by its respective item id, followed by a dash and the element. For example, `{id}-trigger`. ## Accessibility @@ -713,6 +748,27 @@ propDescriptors={[
+### Accordion Item + + + +
+ ### Accordion Header { /* allows animate / transition from display none */ useTask$(function animateContentTask({ track }) { + if (!animated) { + return; + } + track(() => isTriggerExpandedSig.value); - if (animated && isTriggerExpandedSig.value) { + if (isTriggerExpandedSig.value) { isContentHiddenSig.value = false; } }); /* calculates height of the content container based on children */ useVisibleTask$(function calculateHeightVisibleTask({ track }) { + if (animated === false) { + return; + } + track(() => isContentHiddenSig.value); - if (animated && totalHeightSig.value === 0) { + if (totalHeightSig.value === 0) { getCalculatedHeight(); } function getCalculatedHeight() { - if (contentElement) { - const contentChildren = Array.from(contentElement.children) as HTMLElement[]; - - contentChildren.forEach((element, index) => { - totalHeightSig.value += element.offsetHeight; - - if (index === contentChildren.length - 1) { - contentElement?.style.setProperty( - '--qwikui-accordion-content-height', - `${totalHeightSig.value}px` - ); - } - }); + if (!contentElement) { + return; } + + const contentChildren = Array.from(contentElement.children) as HTMLElement[]; + + contentChildren.forEach((element, index) => { + totalHeightSig.value += element.offsetHeight; + + if (index === contentChildren.length - 1) { + contentElement.style.setProperty( + '--qwikui-accordion-content-height', + `${totalHeightSig.value}px` + ); + } + }); } }); return ( - <> - - + ); }); diff --git a/packages/kit-headless/src/components/accordion/accordion-context.type.ts b/packages/kit-headless/src/components/accordion/accordion-context.type.ts index 1cd83ae2e..4017afdd8 100644 --- a/packages/kit-headless/src/components/accordion/accordion-context.type.ts +++ b/packages/kit-headless/src/components/accordion/accordion-context.type.ts @@ -1,6 +1,7 @@ import { Signal, QRL } from '@builder.io/qwik'; export interface AccordionRootContext { + updateTriggers$: QRL<() => void>; focusFirstTrigger$: QRL<() => void>; focusPreviousTrigger$: QRL<() => void>; focusNextTrigger$: QRL<() => void>; @@ -8,7 +9,7 @@ export interface AccordionRootContext { currFocusedTriggerIndexSig: Signal; currSelectedTriggerIndexSig: Signal; selectedTriggerIdSig: Signal; - triggerStore: HTMLButtonElement[]; + triggerElementsSig: Signal; collapsible: boolean; behavior?: string; animated?: boolean; diff --git a/packages/kit-headless/src/components/accordion/accordion-header.tsx b/packages/kit-headless/src/components/accordion/accordion-header.tsx index 5dde70729..a198d706d 100644 --- a/packages/kit-headless/src/components/accordion/accordion-header.tsx +++ b/packages/kit-headless/src/components/accordion/accordion-header.tsx @@ -1,4 +1,10 @@ -import { component$, Slot, type QwikIntrinsicElements } from '@builder.io/qwik'; +import { + component$, + Slot, + useContext, + type QwikIntrinsicElements +} from '@builder.io/qwik'; +import { accordionItemContextId } from './accordion-context-id'; type HeadingUnion = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; @@ -8,10 +14,14 @@ export type AccordionHeaderProps = QwikIntrinsicElements[HeadingUnion] & { export const AccordionHeader = component$( ({ as = 'h3', ...props }: AccordionHeaderProps) => { + const itemContext = useContext(accordionItemContextId); + const itemId = itemContext.itemId; + const headerId = `${itemId}-header`; + const PolymorphicHeading = as; return ( - + ); diff --git a/packages/kit-headless/src/components/accordion/accordion-item.tsx b/packages/kit-headless/src/components/accordion/accordion-item.tsx index f1ab2759e..9a91c602d 100644 --- a/packages/kit-headless/src/components/accordion/accordion-item.tsx +++ b/packages/kit-headless/src/components/accordion/accordion-item.tsx @@ -1,9 +1,9 @@ import { - component$, - useSignal, Slot, - useId, + component$, useContextProvider, + useId, + useSignal, type QwikIntrinsicElements } from '@builder.io/qwik'; @@ -16,8 +16,9 @@ export type AccordionItemProps = { } & QwikIntrinsicElements['div']; export const AccordionItem = component$( - ({ defaultValue = false, ...props }: AccordionItemProps) => { - const itemId = useId(); + ({ defaultValue = false, id, ...props }: AccordionItemProps) => { + const localId = useId(); + const itemId = id || localId; const isTriggerExpandedSig = useSignal(defaultValue); diff --git a/packages/kit-headless/src/components/accordion/accordion-root.tsx b/packages/kit-headless/src/components/accordion/accordion-root.tsx index c704621ae..ef64bbcda 100644 --- a/packages/kit-headless/src/components/accordion/accordion-root.tsx +++ b/packages/kit-headless/src/components/accordion/accordion-root.tsx @@ -4,10 +4,10 @@ import { Slot, useContextProvider, useSignal, - useStore, useTask$, type QwikIntrinsicElements, - type PropFunction + type PropFunction, + useVisibleTask$ } from '@builder.io/qwik'; import { type AccordionRootContext } from './accordion-context.type'; @@ -16,6 +16,7 @@ import { accordionRootContextId } from './accordion-context-id'; export type AccordionRootProps = { behavior?: 'single' | 'multi'; animated?: boolean; + enhance?: boolean; collapsible?: boolean; onSelectedIndexChange$?: PropFunction<(index: number) => void>; onFocusIndexChange$?: PropFunction<(index: number) => void>; @@ -31,9 +32,11 @@ export const AccordionRoot = component$( ...props }: AccordionRootProps) => { const rootRef = useSignal(); - const triggerStore = useStore([]); + const rootElement = rootRef.value; const currFocusedTriggerIndexSig = useSignal(-1); const currSelectedTriggerIndexSig = useSignal(-1); + const selectedTriggerIdSig = useSignal(''); + const triggerElementsSig = useSignal([]); useTask$(({ track }) => { track(() => currSelectedTriggerIndexSig.value); @@ -49,47 +52,70 @@ export const AccordionRoot = component$( } }); - const selectedTriggerIdSig = useSignal(''); + const updateTriggers$ = $(() => { + if (!rootElement) { + return; + } + + // needs to grab a new array when adding or removing elements dynamically. + const getLatestTriggers = Array.from( + rootElement.querySelectorAll('[data-trigger-id]') + ) as HTMLButtonElement[]; + + triggerElementsSig.value = getLatestTriggers.filter((element) => { + if (element.getAttribute('aria-disabled') === 'true') { + return false; + } + + return true; + }); + }); const focusPreviousTrigger$ = $(() => { if (currFocusedTriggerIndexSig.value === 0) { - currFocusedTriggerIndexSig.value = triggerStore.length - 1; - return triggerStore[triggerStore.length - 1].focus(); + currFocusedTriggerIndexSig.value = triggerElementsSig.value.length - 1; + return triggerElementsSig.value[triggerElementsSig.value.length - 1].focus(); } currFocusedTriggerIndexSig.value--; - return triggerStore[currFocusedTriggerIndexSig.value].focus(); + return triggerElementsSig.value[currFocusedTriggerIndexSig.value].focus(); }); const focusNextTrigger$ = $(() => { - if (currFocusedTriggerIndexSig.value === triggerStore.length - 1) { + if (currFocusedTriggerIndexSig.value === triggerElementsSig.value.length - 1) { currFocusedTriggerIndexSig.value = 0; - return triggerStore[0].focus(); + return triggerElementsSig.value[0].focus(); } currFocusedTriggerIndexSig.value++; - return triggerStore[currFocusedTriggerIndexSig.value].focus(); + return triggerElementsSig.value[currFocusedTriggerIndexSig.value].focus(); }); const focusFirstTrigger$ = $(() => { - return triggerStore[0].focus(); + return triggerElementsSig.value[0].focus(); }); const focusLastTrigger$ = $(() => { - return triggerStore[triggerStore.length - 1].focus(); + return triggerElementsSig.value[triggerElementsSig.value.length - 1].focus(); + }); + + // takes a role call of its children (reactive b/c it's a signal) + useVisibleTask$(function reIndexTriggers() { + updateTriggers$(); }); const contextService: AccordionRootContext = { - selectedTriggerIdSig, - currFocusedTriggerIndexSig, - currSelectedTriggerIndexSig, + updateTriggers$, focusFirstTrigger$, focusPreviousTrigger$, focusNextTrigger$, focusLastTrigger$, - triggerStore, + currFocusedTriggerIndexSig, + currSelectedTriggerIndexSig, + selectedTriggerIdSig, + triggerElementsSig, collapsible, behavior, animated diff --git a/packages/kit-headless/src/components/accordion/accordion-trigger.tsx b/packages/kit-headless/src/components/accordion/accordion-trigger.tsx index e98f5aed4..74abcc1dc 100644 --- a/packages/kit-headless/src/components/accordion/accordion-trigger.tsx +++ b/packages/kit-headless/src/components/accordion/accordion-trigger.tsx @@ -39,9 +39,11 @@ export const AccordionTrigger = component$( const collapsible = contextService.collapsible; const defaultValue = itemContext.defaultValue; - const triggerStore = contextService.triggerStore; + const triggerElementsSig = contextService.triggerElementsSig; const triggerId = `${itemContext.itemId}-trigger`; + const updateTriggers$ = contextService.updateTriggers$; + /* content panel id for aria-controls */ const contentId = `${itemContext.itemId}-content`; @@ -54,14 +56,17 @@ export const AccordionTrigger = component$( const setSelectedTriggerIndexSig$ = $(() => { if (behavior === 'single' && triggerElement) { - currSelectedTriggerIndexSig.value = triggerStore.indexOf(triggerElement); + currSelectedTriggerIndexSig.value = + triggerElementsSig.value.indexOf(triggerElement); } }); const setCurrFocusedIndexSig$ = $(() => { - if (triggerElement) { - currFocusedTriggerIndexSig.value = triggerStore.indexOf(triggerElement); + if (!triggerElement) { + return; } + + currFocusedTriggerIndexSig.value = triggerElementsSig.value.indexOf(triggerElement); }); useTask$(function resetTriggersTask({ track }) { @@ -79,8 +84,13 @@ export const AccordionTrigger = component$( }); useVisibleTask$(function navigateTriggerVisibleTask({ cleanup }) { - if (triggerElement && !disabled) { - triggerStore.push(triggerElement); + if (!triggerElement) { + return; + } + + /* runs each time a new trigger is added. We need to tell the root it's time to take a role call. */ + if (!disabled) { + updateTriggers$(); } function keyHandler(e: KeyboardEvent) { @@ -89,7 +99,7 @@ export const AccordionTrigger = component$( } } - triggerElement?.addEventListener('keydown', keyHandler); + triggerElement.addEventListener('keydown', keyHandler); cleanup(() => { triggerElement?.removeEventListener('keydown', keyHandler); }); @@ -98,14 +108,11 @@ export const AccordionTrigger = component$( useVisibleTask$( function cleanupTriggersTask({ cleanup }) { cleanup(() => { - if (triggerElement) { - triggerStore.splice(triggerStore.indexOf(triggerElement), 1); - } + updateTriggers$(); }); }, - { strategy: 'document-idle' } + { strategy: 'document-ready' } ); - return ( - - // - //
- // - // ); - // } - // ); - - // it(`GIVEN 3 accordion items - // WHEN removing the 3rd one dynamically - // THEN only 2 should remain - // `, () => { - // cy.mount( - // - // ); - - // cy.findByRole('button', { name: /remove item/i }).click(); - - // cy.get('[data-trigger-id]').should('have.length', 2); - // }); - - // it(`GIVEN 3 accordion items - // WHEN clicking on the 3rd trigger and adding a new one at the start - // THEN the label and content should change, but the index remain the same`, () => { - // cy.mount(); - - // cy.get('[data-trigger-id]').eq(2).click(); - // cy.findByRole('button', { name: /add item/i }).click(); - - // cy.get('[data-trigger-id]').eq(2); - // }); - - // it(`GIVEN 4 accordion items - // WHEN clicking on 3rd trigger and removing it - // THEN the 4th trigger's content should be open`, () => { - // cy.mount(); - - // cy.findByRole('button', { name: /Trigger Item 3/i }).click(); - // cy.findByRole('region').should('contain', 'Content Item 3'); - - // cy.findByRole('button', { name: /Remove Item/i }).click(); - // cy.findByRole('region').should('contain', 'Content Item 4'); - // }); + const DynamicAccordion = component$( + ({ + itemIndexToAdd = 0, + itemIndexToDelete = 0, + itemsLength + }: DynamicAccordionProps) => { + // start off with some items + const items = []; + const newItem = { label: 'New Item', id: Math.random() }; + + for (let i = 0; i < itemsLength; i++) { + items.push({ + label: `Original Item ${i + 1}`, + id: Math.random() + }); + } + + const itemStore = useStore<{ label: string; id: number }[]>(items); + + return ( + <> + + {itemStore.map(({ label, id }, index) => { + return ( + + {label} + index: {index} + + ); + })} + + +
+ + + +
+ + ); + } + ); + + it(`GIVEN 3 accordion items + WHEN removing the 3rd one dynamically + THEN only 2 should remain + `, () => { + cy.mount(); + + cy.findByRole('button', { name: /remove item/i }).click(); + + cy.get('[data-trigger-id]').should('have.length', 2); + }); + + it(`GIVEN 3 accordion items + WHEN clicking on the 3rd trigger and adding a new one at the start + THEN the label and content should be moved down, and the index should be one higher.`, () => { + cy.mount(); + + cy.get('[data-trigger-id]').eq(2).click(); + cy.findByRole('button', { name: /add item/i }).click(); + + cy.findByRole('region').contains('index: 3'); + }); + + it(`GIVEN 3 accordion items + WHEN clicking on the 3rd trigger and removing one at the start + THEN the label and content should be moved up, and the index should be one lower`, () => { + cy.mount(); + + cy.get('[data-trigger-id]').eq(2).click(); + cy.findByRole('button', { name: /remove item/i }).click(); + + cy.findByRole('region').contains('index: 1'); + }); + + it(`GIVEN 4 accordion items + WHEN clicking on 3rd trigger and removing it + THEN there shouldn't be any open items`, () => { + cy.mount(); + + cy.findByRole('button', { name: /Original Item 3/i }).click(); + cy.findByRole('region').contains('index: 2'); + + cy.findByRole('button', { name: /Remove Item/i }).click(); + cy.findByRole('region').should('not.exist'); + }); + + it(`GIVEN 2 accordion items + WHEN adding two new items at index 0 and focusing the 2nd item + THEN it should focus the 3rd. + `, () => { + cy.mount(); + + cy.findByRole('button', { name: /add item/i }) + .click() + .click(); + + cy.findAllByRole('button').eq(0).focus().type(`{downArrow}`); + cy.findAllByRole('button').eq(1).focus().type(`{downArrow}`); + cy.findAllByRole('button').eq(2).should('have.focus'); + }); }); diff --git a/packages/kit-headless/src/components/autocomplete/autocomplete-input.tsx b/packages/kit-headless/src/components/autocomplete/autocomplete-input.tsx index cb518d673..15130d7b1 100644 --- a/packages/kit-headless/src/components/autocomplete/autocomplete-input.tsx +++ b/packages/kit-headless/src/components/autocomplete/autocomplete-input.tsx @@ -57,9 +57,7 @@ export const AutocompleteInput = component$((props: InputProps) => { const optionValue = option.value.getAttribute('optionValue'); const inputValue = contextService.inputValue.value; - const defaultFilterRegex = '[0-9]*'; - const defaultFilterPattern = inputValue + defaultFilterRegex; - const defaultFilter = new RegExp(defaultFilterPattern, 'i'); + const defaultFilter = new RegExp(inputValue, 'i'); if ( contextService.inputValue.value.length >= 0 &&