From 5c338fbfc73d9d20c8f771e11114064cadeeb732 Mon Sep 17 00:00:00 2001 From: Westbrook Johnson Date: Thu, 30 Sep 2021 08:58:22 -0500 Subject: [PATCH] fix: add likeAnchor API to Card element --- packages/card/README.md | 18 +++- packages/card/src/Card.ts | 119 +++++++++++++++++++------- packages/card/src/card.css | 23 +++++ packages/card/stories/card.stories.ts | 63 ++++++++++++++ packages/card/test/card.test.ts | 106 +++++++++++++++++++++++ packages/shared/src/like-anchor.ts | 6 ++ 6 files changed, 304 insertions(+), 31 deletions(-) diff --git a/packages/card/README.md b/packages/card/README.md index b480911f19..f34e27839d 100644 --- a/packages/card/README.md +++ b/packages/card/README.md @@ -49,7 +49,7 @@ import { Card } from '@spectrum-web-components/card'; ## Heading -By default, the heading for an `sp-card` is applied via the `heading` attribute, which is restricted to string content only. When HTML content is desired, a slot named `heading` available for applying the heading. +By default, the heading for an `` is applied via the `heading` attribute, which is restricted to string content only. When HTML content is desired, a slot named `heading` available for applying the heading. ```html demo ``` +## Linking + +An `` can be provided with an `href` attribute in order for it to act as one large anchor element. When leveraging the `href` attribute, the `download`, `target` and `rel` attributes come into play to customize the linking behavior of the element. Use them as follows: + + +```html + + Demo Image + +``` + ## Variants There are multiple card variants to choose from in Spectrum. The `variant` diff --git a/packages/card/src/Card.ts b/packages/card/src/Card.ts index 9ba5b0771e..b83e232482 100644 --- a/packages/card/src/Card.ts +++ b/packages/card/src/Card.ts @@ -18,11 +18,13 @@ import { TemplateResult, PropertyValues, ifDefined, + query, } from '@spectrum-web-components/base'; import { FocusVisiblePolyfillMixin } from '@spectrum-web-components/shared/src/focus-visible.js'; +import { ObserveSlotPresence } from '@spectrum-web-components/shared/src/observe-slot-presence.js'; +import { LikeAnchor } from '@spectrum-web-components/shared/src/like-anchor.js'; import '@spectrum-web-components/asset/sp-asset.js'; -import { ObserveSlotPresence } from '@spectrum-web-components/shared'; import { Checkbox } from '@spectrum-web-components/checkbox/src/Checkbox'; import '@spectrum-web-components/checkbox/sp-checkbox.js'; import '@spectrum-web-components/quick-actions/sp-quick-actions.js'; @@ -42,9 +44,11 @@ import detailStyles from '@spectrum-web-components/styles/detail.js'; * @slot actions - an `sp-action-menu` element outlining actions to take on the represened object * @slot footer - Footer text */ -export class Card extends ObserveSlotPresence( - FocusVisiblePolyfillMixin(SpectrumElement), - ['[slot="cover-photo"]', '[slot="preview"]'] +export class Card extends LikeAnchor( + ObserveSlotPresence(FocusVisiblePolyfillMixin(SpectrumElement), [ + '[slot="cover-photo"]', + '[slot="preview"]', + ]) ) { public static get styles(): CSSResultArray { return [headingStyles, detailStyles, cardStyles]; @@ -65,6 +69,9 @@ export class Card extends ObserveSlotPresence( @property({ type: Boolean, reflect: true }) public horizontal = false; + @query('#like-anchor') + private likeAnchor?: HTMLAnchorElement; + @property({ type: String, reflect: true }) public size?: 's'; @@ -92,6 +99,10 @@ export class Card extends ObserveSlotPresence( this.addEventListener('focusout', this.handleFocusout); } + public click(): void { + this.likeAnchor?.click(); + } + private handleFocusin = (event: Event): void => { this.focused = true; const target = event.composedPath()[0]; @@ -112,8 +123,17 @@ export class Card extends ObserveSlotPresence( private handleKeydown(event: KeyboardEvent): void { const { code } = event; - if (code === 'Space') { - this.toggleSelected(); + switch (code) { + case 'Space': + this.toggleSelected(); + if (this.toggles) { + break; + } + case 'Enter': + case 'NumpadEnter': + if (this.href) { + this.likeAnchor?.click(); + } } } @@ -149,9 +169,35 @@ export class Card extends ObserveSlotPresence( } } + private stopPropagationOnHref(event: Event): void { + if (this.href) { + event.stopPropagation(); + } + } + + private handlePointerdown(event: Event): void { + const path = event.composedPath(); + const hasAnchor = path.some( + (el) => (el as HTMLElement).localName === 'a' + ); + if (hasAnchor) return; + const start = +new Date(); + const handleEnd = (): void => { + const end = +new Date(); + if (end - start < 200) { + this.likeAnchor?.click(); + } + this.removeEventListener('pointerup', handleEnd); + }; + this.addEventListener('pointerup', handleEnd); + } + protected get renderHeading(): TemplateResult { return html` -
+
${this.heading}
`; @@ -201,26 +247,6 @@ export class Card extends ObserveSlotPresence( protected render(): TemplateResult { return html` - ${this.toggles - ? html` - - - - ` - : html``} - ${this.variant === 'quiet' && this.size === 's' - ? html` - - - - ` - : html``} - ${this.renderImage()}
${this.renderHeading} @@ -229,7 +255,10 @@ export class Card extends ObserveSlotPresence( : html``} ${this.variant !== 'quiet' || this.size !== 's' ? html` -
+
` @@ -243,17 +272,47 @@ export class Card extends ObserveSlotPresence( ` : html``}
+ ${this.href + ? this.renderAnchor({ + id: 'like-anchor', + labelledby: 'heading', + }) + : html``} ${this.variant === 'standard' ? html` ` : html``} + ${this.renderImage()} + ${this.toggles + ? html` + + + + ` + : html``} + ${this.variant === 'quiet' && this.size === 's' + ? html` + + + + ` + : html``} `; } protected firstUpdated(changes: PropertyValues): void { super.firstUpdated(changes); - this.setAttribute('role', 'figure'); - this.tabIndex = 0; + this.addEventListener('pointerdown', this.handlePointerdown); } } diff --git a/packages/card/src/card.css b/packages/card/src/card.css index e6ff6c903a..b9d046e5de 100644 --- a/packages/card/src/card.css +++ b/packages/card/src/card.css @@ -12,6 +12,28 @@ governing permissions and limitations under the License. @import './spectrum-card.css'; +:host([href]:not([href=''])) { + cursor: pointer; +} + +#like-anchor { + position: absolute; + inset: 0; + pointer-events: none; +} + +.actionButton { + flex-grow: 0; +} + +:host([dir='ltr']) .actionButton { + margin-left: auto; +} + +:host([dir='rtl']) .actionButton { + margin-right: auto; +} + /* The description slot has a psuedo-element that also needs to receive the font styling. We need to add the declaration to the slot as well */ slot[name='description'] { @@ -24,6 +46,7 @@ slot[name='description'] { #preview, #cover-photo { overflow: hidden; + order: -1; } #preview + #cover-photo { diff --git a/packages/card/stories/card.stories.ts b/packages/card/stories/card.stories.ts index 7ad12fddc6..dc87fb1571 100644 --- a/packages/card/stories/card.stories.ts +++ b/packages/card/stories/card.stories.ts @@ -19,6 +19,7 @@ import '@spectrum-web-components/action-menu/sp-action-menu.js'; import '@spectrum-web-components/menu/sp-menu.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; import '@spectrum-web-components/menu/sp-menu-divider.js'; +import '@spectrum-web-components/link/sp-link.js'; export default { component: 'sp-card', @@ -54,6 +55,7 @@ export default { export interface StoryArgs { horizontal?: boolean; size?: 's'; + onClick?: ((event: Event) => void) | undefined; } export const Default = (args: StoryArgs): TemplateResult => { @@ -71,6 +73,45 @@ export const Default = (args: StoryArgs): TemplateResult => { }; Default.args = {}; +export const href = (args: StoryArgs): TemplateResult => { + const { onClick } = args; + return html` + { + const composedTarget = event.composedPath()[0] as HTMLElement; + if (composedTarget.id !== 'like-anchor') return; + event.stopPropagation(); + event.preventDefault(); + onClick && onClick(event); + }} + > +
+ Footer with a + link to Google +
+ + Deselect + Select Inverse + Feather... + Select and Mask... + + Save Selection + Make Work Path + + Demo Graphic +
+ `; +}; +href.argTypes = { + onClick: { action: 'link click' }, +}; + export const actions = (args: StoryArgs): TemplateResult => { return html` { + return html` + + + + `; +}; +smallHorizontalWithHREF.args = { + horizontal: true, + size: 's', +}; + export const smallQuiet = (args: StoryArgs): TemplateResult => { return html`
diff --git a/packages/card/test/card.test.ts b/packages/card/test/card.test.ts index 43c6d84bb4..2e0168b3b0 100644 --- a/packages/card/test/card.test.ts +++ b/packages/card/test/card.test.ts @@ -20,12 +20,14 @@ import { fixture, elementUpdated, html, expect } from '@open-wc/testing'; import { Default, + href, smallHorizontal, StoryArgs, } from '../stories/card.stories.js'; import { Checkbox } from '@spectrum-web-components/checkbox/src/Checkbox'; import { spy } from 'sinon'; import { spaceEvent } from '../../../test/testing-helpers.js'; +import { executeServerCommand } from '@web/test-runner-commands'; describe('card', () => { it('loads', async () => { @@ -137,6 +139,110 @@ describe('card', () => { await expect(el).to.be.accessible(); }); + it('[href] is clickable', async () => { + const clickSpy = spy(); + const el = await fixture(href({})); + + await elementUpdated(el); + + el.addEventListener('click', (event: Event) => { + const composedTarget = event.composedPath()[0] as HTMLElement; + if (composedTarget.id !== 'like-anchor') return; + clickSpy(); + }); + + el.click(); + + expect(clickSpy.callCount).to.equal(1); + + (el.shadowRoot.querySelector('#like-anchor') as HTMLElement).click(); + + expect(clickSpy.callCount).to.equal(2); + + const img = el.querySelector('img') as HTMLImageElement; + const boundingRect = img.getBoundingClientRect(); + await executeServerCommand('send-mouse', { + steps: [ + { + type: 'move', + position: [ + boundingRect.x + boundingRect.width / 2, + boundingRect.y + boundingRect.height / 2, + ], + }, + { + type: 'down', + }, + { + type: 'up', + }, + ], + }); + + expect(clickSpy.callCount).to.equal(3); + }); + it('links in [href] do not pass their click', async () => { + const clickSpy = spy(); + const el = await fixture(href({})); + el.setAttribute( + 'style', + 'width: 200px; --spectrum-actionbutton-height: 32px' + ); + + await elementUpdated(el); + + el.addEventListener('click', (event: Event) => { + const composedTarget = event.composedPath()[0] as HTMLElement; + event.preventDefault(); + if (composedTarget.id !== 'like-anchor') return; + clickSpy(); + }); + + el.click(); + + expect(clickSpy.callCount).to.equal(1); + + const footer = el.querySelector('[slot="footer"]') as HTMLElement; + let boundingRect = footer.getBoundingClientRect(); + await executeServerCommand('send-mouse', { + steps: [ + { + type: 'move', + position: [boundingRect.x, boundingRect.y], + }, + { + type: 'down', + }, + { + type: 'up', + }, + ], + }); + + expect(clickSpy.callCount).to.equal(2); + + const link = el.querySelector('sp-link') as HTMLElement; + boundingRect = link.getBoundingClientRect(); + await executeServerCommand('send-mouse', { + steps: [ + { + type: 'move', + position: [ + boundingRect.x + boundingRect.width / 2, + boundingRect.y + boundingRect.height / 2, + ], + }, + { + type: 'down', + }, + { + type: 'up', + }, + ], + }); + + expect(clickSpy.callCount).to.equal(2); + }); it('converts `Space` to `click` event', async () => { const clickSpy = spy(); const handleClick = (): void => clickSpy(); diff --git a/packages/shared/src/like-anchor.ts b/packages/shared/src/like-anchor.ts index 864c030f02..d5d42d7e25 100644 --- a/packages/shared/src/like-anchor.ts +++ b/packages/shared/src/like-anchor.ts @@ -28,6 +28,8 @@ type RenderAnchorOptions = { className?: string; ariaHidden?: boolean; anchorContent?: TemplateResult | TemplateResult[]; + labelledby?: string; + tabindex?: -1 | 0; }; export interface LikeAnchorInterface { @@ -62,6 +64,8 @@ export function LikeAnchor>( id, className, ariaHidden, + labelledby, + tabindex, // prettier-ignore anchorContent = html``, }: RenderAnchorOptions): TemplateResult { @@ -74,7 +78,9 @@ export function LikeAnchor>( download=${ifDefined(this.download)} target=${ifDefined(this.target)} aria-label=${ifDefined(this.label)} + aria-labelledby=${ifDefined(labelledby)} aria-hidden=${ifDefined(ariaHidden ? 'true' : undefined)} + tabindex=${ifDefined(tabindex)} rel=${ifDefined(this.rel)} >${anchorContent}`; }