Skip to content

Commit 5d65ffd

Browse files
feat(popover): Making the solution more reliable
1 parent c596e24 commit 5d65ffd

File tree

6 files changed

+117
-56
lines changed

6 files changed

+117
-56
lines changed

apps/website/src/routes/docs/headless/(components)/popover/examples.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ export const MainExample = component$(() => {
1010
<PopoverContent>
1111
<div class="p-4 bg-gray-500">Hi, I'm the content</div>
1212
</PopoverContent>
13-
<PopoverTrigger ariaAttributes={{ ariaLabel: 'click-friend' }}>
14-
Click on me
15-
</PopoverTrigger>
13+
<PopoverTrigger ariaLabel="Freund">Click on me</PopoverTrigger>
1614
</Popover>
1715
</div>
1816
<div q:slot="codeExample">
@@ -32,7 +30,7 @@ export const Example1 = component$(() => {
3230
Hi, I'm the content, but now on top
3331
</div>
3432
</PopoverContent>
35-
<PopoverTrigger ariaAttributes={{ ariaLabel: 'no-click-friend' }}>
33+
<PopoverTrigger ariaLabel="no-click-friend">
3634
Click on me
3735
</PopoverTrigger>
3836
</Popover>

packages/kit-headless/src/components/popover/popover-trigger.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,37 @@ import {
44
component$,
55
useContext,
66
useSignal,
7+
useStore,
78
useStylesScoped$,
9+
useTask$,
810
useVisibleTask$,
911
} from '@builder.io/qwik';
10-
import { ExtendedPropsByAriaAttribute } from '../../utils';
11-
import { useAriaAttributes } from '../../utils/aria-attributes-helper';
12+
import {
13+
ExtendedPropsByAriaAttribute,
14+
QwikUiAreaAttributesFunctionReturnType,
15+
getAriaAttributes,
16+
} from '../../utils';
1217
import { PopoverContext } from './popover-context';
1318
import styles from './popover-trigger.css?inline';
1419

1520
export const PopoverTrigger = component$(
16-
({ ariaAttributes }: ExtendedPropsByAriaAttribute) => {
21+
(props: ExtendedPropsByAriaAttribute) => {
1722
const ref = useSignal<HTMLElement>();
1823
const contextService = useContext(PopoverContext);
1924
useStylesScoped$(styles);
25+
const store = useStore<Partial<QwikUiAreaAttributesFunctionReturnType>>({
26+
lastKey: undefined,
27+
ariaAttributes: {},
28+
});
29+
useTask$(({ track }) => {
30+
track(() => ({ ...props }));
31+
const { lastKey, ariaAttributes } = getAriaAttributes(
32+
props,
33+
store.lastKey
34+
);
35+
store.ariaAttributes = ariaAttributes;
36+
store.lastKey = lastKey;
37+
});
2038

2139
useVisibleTask$(() => {
2240
contextService.setTriggerRef$(ref);
@@ -25,13 +43,10 @@ export const PopoverTrigger = component$(
2543
const mouseOverHandler = $(() => {
2644
contextService.isOpen = true;
2745
});
28-
29-
const interesstingAriaAttributes = useAriaAttributes(ariaAttributes);
30-
3146
return (
3247
<span
3348
ref={ref}
34-
{...interesstingAriaAttributes}
49+
{...store.ariaAttributes}
3550
role="button"
3651
class="popover-trigger"
3752
onMouseOver$={

packages/kit-headless/src/components/popover/popover.spec.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
import { component$ } from '@builder.io/qwik';
1+
import { AriaAttributes, component$ } from '@builder.io/qwik';
2+
import { ExtendedPropsByAriaAttribute } from '../../utils';
23
import { Popover, PopoverProps } from './popover';
34
import { PopoverContent } from './popover-content';
45
import { PopoverTrigger } from './popover-trigger';
56

6-
const PopoverComponent = component$((props: PopoverProps) => {
7-
return (
8-
<Popover {...props}>
9-
<PopoverContent>popover content</PopoverContent>
10-
<PopoverTrigger>trigger text</PopoverTrigger>
11-
</Popover>
12-
);
13-
});
7+
const PopoverComponent = component$(
8+
(props: ExtendedPropsByAriaAttribute<PopoverProps>) => {
9+
return (
10+
<Popover {...props}>
11+
<PopoverContent>popover content</PopoverContent>
12+
<PopoverTrigger {...props}>trigger text</PopoverTrigger>
13+
</Popover>
14+
);
15+
}
16+
);
1417

1518
describe('Popover', () => {
1619
function clickOnTrigger() {
@@ -35,6 +38,18 @@ describe('Popover', () => {
3538
cy.findByRole('button').trigger('mouseover');
3639
}
3740

41+
function assertAriaAttribute(attributes: AriaAttributes) {
42+
const trigger = cy.findByRole('button');
43+
Object.keys(attributes).forEach((attributeKey) => {
44+
trigger.should(
45+
'have.a.property',
46+
`[${attributeKey}]="${
47+
attributes[attributeKey as keyof AriaAttributes]
48+
}]`
49+
);
50+
});
51+
}
52+
3853
it('INIT', () => {
3954
cy.mount(<PopoverComponent />);
4055

@@ -110,4 +125,10 @@ describe('Popover', () => {
110125

111126
assertClosed();
112127
});
128+
129+
it('should set the arial label', () => {
130+
const label = 'hello';
131+
cy.mount(<PopoverComponent ariaLabel={label} />);
132+
assertAriaAttribute({ 'aria-label': label });
133+
});
113134
});
Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
12
import { AriaAttributes } from '@builder.io/qwik';
23
import {
4+
ExtendedPropsByAriaAttribute,
5+
QwikUiAreaAttributesFunctionType,
36
QwikUiAriaAttributesKebab,
47
isKeyOfAriaAttributes,
58
isKeyOfQwikUiAriaAttributes,
@@ -16,42 +19,61 @@ export function keyToKebabCase(str: string): keyof AriaAttributes {
1619
}
1720
}
1821

19-
type FunctionType = (
20-
ariaAttributes?: Partial<QwikUiAriaAttributesKebab>
21-
) => Partial<AriaAttributes>;
22-
2322
const cacheMap: Map<string, Partial<AriaAttributes>> = new Map();
2423

25-
const memoize = (func: FunctionType): FunctionType => {
24+
const memoize = (
25+
func: QwikUiAreaAttributesFunctionType
26+
): QwikUiAreaAttributesFunctionType => {
2627
return (
27-
ariaAttributes?: Partial<QwikUiAriaAttributesKebab>
28-
): Partial<AriaAttributes> => {
29-
const key = JSON.stringify(ariaAttributes);
30-
if (!cacheMap.has(key) || !ariaAttributes) {
31-
const out = func(ariaAttributes);
32-
cacheMap.set(key, out);
33-
return out;
28+
qwikUiAriaAttributes?: Partial<QwikUiAriaAttributesKebab>,
29+
lastKey?: string
30+
): ReturnType<QwikUiAreaAttributesFunctionType> => {
31+
const key = JSON.stringify(qwikUiAriaAttributes);
32+
if (lastKey) {
33+
cacheMap.delete(lastKey);
34+
}
35+
if (!cacheMap.has(key) || !qwikUiAriaAttributes) {
36+
const { ariaAttributes } = func(qwikUiAriaAttributes);
37+
cacheMap.set(key, ariaAttributes);
38+
return { lastKey: key, ariaAttributes };
3439
} else {
35-
return cacheMap.get(key) || {};
40+
return { lastKey: key, ariaAttributes: cacheMap.get(key) || {} };
3641
}
3742
};
3843
};
3944

40-
export const useAriaAttributes = (
41-
ariaAttributes?: Partial<QwikUiAriaAttributesKebab>
42-
): Partial<AriaAttributes> => {
45+
export const extractQwikUiAriaAttributes = <T = any>(
46+
props: ExtendedPropsByAriaAttribute<T>
47+
) => {
48+
return Object.keys(props).reduce(
49+
(cur, propKey) =>
50+
isKeyOfQwikUiAriaAttributes(propKey)
51+
? { ...cur, [propKey]: props[propKey] }
52+
: cur,
53+
{}
54+
);
55+
};
56+
57+
export const getAriaAttributes = <T = any>(
58+
props: ExtendedPropsByAriaAttribute<T>,
59+
lastKey?: string
60+
): ReturnType<QwikUiAreaAttributesFunctionType> => {
4361
const process = (
44-
ariaAttributes?: Partial<QwikUiAriaAttributesKebab>
45-
): Partial<AriaAttributes> => {
46-
return ariaAttributes
47-
? Object.keys(ariaAttributes).reduce(
48-
(cur, key) =>
49-
isKeyOfQwikUiAriaAttributes(key)
50-
? { ...cur, [keyToKebabCase(key)]: ariaAttributes[key] }
51-
: cur,
52-
{}
53-
)
54-
: {};
62+
qwikUiAriaAttributes?: Partial<QwikUiAriaAttributesKebab>
63+
): ReturnType<QwikUiAreaAttributesFunctionType> => {
64+
return {
65+
lastKey: JSON.stringify(qwikUiAriaAttributes),
66+
ariaAttributes: qwikUiAriaAttributes
67+
? Object.keys(qwikUiAriaAttributes).reduce(
68+
(cur, key) =>
69+
isKeyOfQwikUiAriaAttributes(key)
70+
? { ...cur, [keyToKebabCase(key)]: qwikUiAriaAttributes[key] }
71+
: cur,
72+
{}
73+
)
74+
: {},
75+
};
5576
};
56-
return memoize(process)(ariaAttributes);
77+
const qwikUiAriaAttributes = extractQwikUiAriaAttributes<T>(props);
78+
return memoize(process)(qwikUiAriaAttributes, lastKey);
5779
};

packages/kit-headless/src/utils/aria-attributes.type.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,30 +22,34 @@ type CamelToKebabKeys<T> = {
2222
[K in keyof T as FromCamelCase<string & K>]: T[K];
2323
};
2424

25-
type AA = {
26-
ariaAttributes?: QwikUiAriaAttributesKebab;
27-
};
28-
2925
export type QwikUiAriaAttributesKebab = KebabToCamelKeys<AriaAttributes>;
3026

3127
export type ExtendedPropsByAriaAttribute<T = undefined> = T extends object
32-
? T & AA
28+
? T & QwikUiAriaAttributesKebab
3329
: T extends undefined
34-
? AA
30+
? QwikUiAriaAttributesKebab
3531
: never;
3632

33+
export type QwikUiAreaAttributesFunctionType = (
34+
ariaAttributes?: Partial<QwikUiAriaAttributesKebab>,
35+
lastKey?: string
36+
) => { lastKey: string; ariaAttributes: Partial<AriaAttributes> };
37+
38+
export type QwikUiAreaAttributesFunctionReturnType =
39+
ReturnType<QwikUiAreaAttributesFunctionType>;
40+
3741
export function isKeyOfQwikUiAriaAttributes(
3842
key: string
3943
): key is keyof QwikUiAriaAttributesKebab {
4044
// const ariaAttributeKeys = propertiesOf<QwikUiAriaAttributesKebab>();
4145
// return ariaAttributeKeys.includes(key as keyof QwikUiAriaAttributesKebab);
42-
return true;
46+
return key.startsWith('aria') && key.indexOf('-') === -1;
4347
}
4448

4549
export function isKeyOfAriaAttributes(
4650
key: string
4751
): key is keyof AriaAttributes {
4852
// const ariaAttributeKeys = propertiesOf<AriaAttributes>();
4953
// return ariaAttributeKeys.includes(key as keyof AriaAttributes);
50-
return true;
54+
return key.startsWith('aria') && key.indexOf('-') > -1;
5155
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * from './aria-attributes-helper';
12
export * from './aria-attributes.type';
23
export * from './key-code.type';

0 commit comments

Comments
 (0)