diff --git a/.changeset/accordion-no-animate.md b/.changeset/accordion-no-animate.md deleted file mode 100644 index b39b5a5efe..0000000000 --- a/.changeset/accordion-no-animate.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -"@patternfly/elements": patch ---- -``: remove animations which are not present in PatternFly specs diff --git a/.changeset/chilly-plums-shake.md b/.changeset/chilly-plums-shake.md new file mode 100644 index 0000000000..1c29867c5d --- /dev/null +++ b/.changeset/chilly-plums-shake.md @@ -0,0 +1,6 @@ +--- +"@patternfly/eslint-config-elements": major +"@patternfly/eslint-plugin-elements": major +--- + +Provide ESLint flat config. Upgrade to ESLint 9.0 to use. diff --git a/.changeset/deep-mammals-rescue.md b/.changeset/deep-mammals-rescue.md new file mode 100644 index 0000000000..da5d640962 --- /dev/null +++ b/.changeset/deep-mammals-rescue.md @@ -0,0 +1,4 @@ +--- +"@patternfly/pfe-tools": patch +--- +Dev Server: update icon and theme colours diff --git a/.changeset/eleven-llamas-follow.md b/.changeset/eleven-llamas-follow.md new file mode 100644 index 0000000000..550b1be5e5 --- /dev/null +++ b/.changeset/eleven-llamas-follow.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": patch +--- +``: fixed computed button label when the placeholder attribute is present diff --git a/.changeset/label-close-remove.md b/.changeset/label-close-remove.md new file mode 100644 index 0000000000..063b1d7797 --- /dev/null +++ b/.changeset/label-close-remove.md @@ -0,0 +1,17 @@ +--- +"@patternfly/elements": major +--- +``: when clicking close button, `close` event is fired. +Now, if that event is not cancelled, the label will remove itself from the document. + +To restore previous behaviour: + +```js +import { LabelCloseEvent } from '@patternfly/elements/pf-label/pf-label.js'; +label.addEventListener('close', function(event) { + if (event instanceof LabelCloseEvent) { + event.preventDefault(); + return false; + } +}); +``` diff --git a/.changeset/logger-debug.md b/.changeset/logger-debug.md deleted file mode 100644 index 794b407b9e..0000000000 --- a/.changeset/logger-debug.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -"@patternfly/pfe-core": patch ---- -`Logger`: add `Logger.info` and `Logger.debug` diff --git a/.changeset/pf-text-input-placeholder.md b/.changeset/pf-text-input-placeholder.md deleted file mode 100644 index cbb24477dc..0000000000 --- a/.changeset/pf-text-input-placeholder.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -"@patternfly/elements": minor ---- -``: added `placeholder` attribute diff --git a/.changeset/remove-base-clipboard-copy.md b/.changeset/remove-base-clipboard-copy.md new file mode 100644 index 0000000000..f121291d7d --- /dev/null +++ b/.changeset/remove-base-clipboard-copy.md @@ -0,0 +1,35 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseClipboardCopy` class. +Reimplement (recommended) or extend `PfClipboardCopy`. +Renames `AvatarLoadEvent` to `PfAvatarLoadEvent` and moves it to `pf-avatar.js`. + +**Before**: + +```js +import { + ClipboardCopyCopiedEvent +} from '@patternfly/elements/pf-clipboard-copy/BaseClipboardCopy.js'; + +addEventListener('copy', function(event) { + if (event instanceof ClipboardCopyCopiedEvent) { + // ... + } +}); +``` + +**After**: + +```js +import { + PfClipboardCopyCopiedEvent +} from '@patternfly/elements/pf-clipboard-copy/pf-clipboard-copy.js'; + +addEventListener('copy', function(event) { + if (event instanceof PfClipboardCopyCopiedEvent) { + // ... + } +}); +``` + diff --git a/.changeset/remove-base-label.md b/.changeset/remove-base-label.md new file mode 100644 index 0000000000..bc79ff2729 --- /dev/null +++ b/.changeset/remove-base-label.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseLabel` class. Reimplement (recommended) or extend `PfLabel`. diff --git a/.changeset/remove-base-switch.md b/.changeset/remove-base-switch.md new file mode 100644 index 0000000000..3cf68ab755 --- /dev/null +++ b/.changeset/remove-base-switch.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseSwitch` class. Reimplement (recommended) or extend `PfSwitch`. diff --git a/.changeset/remove-baseavatar.md b/.changeset/remove-baseavatar.md new file mode 100644 index 0000000000..b489d1c261 --- /dev/null +++ b/.changeset/remove-baseavatar.md @@ -0,0 +1,29 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseAvatar` class. Reimplement (recommended) or extend `PfAvatar`. +Renames `AvatarLoadEvent` to `PfAvatarLoadEvent` and moves it to `pf-avatar.js`. + +**Before**: + +```js +import { AvatarLoadEvent } from '@patternfly/elements/pf-avatar/BaseAvatar.js'; + +addEventListener('load', function(event) { + if (event instanceof AvatarLoadEvent) { + // ... + } +}); +``` + +**After**: + +```js +import { PfAvatarLoadEvent } from '@patternfly/elements/pf-avatar/pf-avatar.js'; + +addEventListener('load', function(event) { + if (event instanceof PfAvatarLoadEvent) { + // ... + } +}); +``` diff --git a/.changeset/remove-basebadge.md b/.changeset/remove-basebadge.md new file mode 100644 index 0000000000..9aa1f1c36b --- /dev/null +++ b/.changeset/remove-basebadge.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseBadge` class. Reimplement (recommended) or extend `PfBadge`. diff --git a/.changeset/remove-basecodeblock.md b/.changeset/remove-basecodeblock.md new file mode 100644 index 0000000000..f135bcf8c1 --- /dev/null +++ b/.changeset/remove-basecodeblock.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseCodeBlock` class. Reimplement (recommended) or extend `PfCodeBlock`. diff --git a/.changeset/remove-basespinner.md b/.changeset/remove-basespinner.md new file mode 100644 index 0000000000..5b4a325b4e --- /dev/null +++ b/.changeset/remove-basespinner.md @@ -0,0 +1,4 @@ +--- +"@patternfly/elements": major +--- +``: Removed `BaseSpinner` class. Reimplement (recommended) or extend `PfSpinner`. diff --git a/.changeset/rude-kiwis-live.md b/.changeset/rude-kiwis-live.md new file mode 100644 index 0000000000..50e57b064b --- /dev/null +++ b/.changeset/rude-kiwis-live.md @@ -0,0 +1,4 @@ +--- +"@patternfly/pfe-tools": patch +--- +Update typescript types diff --git a/.changeset/slot-logger.md b/.changeset/slot-logger.md deleted file mode 100644 index ddf06fff53..0000000000 --- a/.changeset/slot-logger.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -"@patternfly/pfe-core": patch ---- -`SlotController`: move debug logs to `Logger.debug` diff --git a/.commitlintrc.cjs b/.commitlintrc.cjs index f2941c00a2..6aaff34752 100644 --- a/.commitlintrc.cjs +++ b/.commitlintrc.cjs @@ -1,5 +1,5 @@ -const fs = require("fs"); -const path = require("path"); +const fs = require('fs'); +const path = require('path'); const normalizeWorkspace = x => fs.readdirSync(path.join(__dirname, x)).map(x => x.replace('pf-', '')); @@ -23,5 +23,5 @@ module.exports = { ...normalizeWorkspace('core'), ...normalizeWorkspace('tools'), ]], - } + }, }; diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 9963171661..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,45 +0,0 @@ -!.eleventy.cjs - -*.css -*.d.ts -*.ico -*.jpeg -*.jpg -*.map -*.md -*.njk -*.patch -*.png -*.sh -*.spec.js -*.svg -*.toml -*.tsbuildinfo -*.txt -*.yml -*.yaml -*.woff* - -custom-elements.json -package-lock.json - -_site -docs/_data/todos.json -docs/demo.js -docs/pfe.min.js -docs/bundle.js -docs/core -docs/components -node_modules - -core/**/*.js -elements/**/*.js -tools/**/*.js - -!core/*/demo/*.js -!elements/*/demo/*.js -elements/*/demo/*.html - -tools/create-element/templates/**/* -node_modules -node_modules/**/* diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index aecb8a524f..0000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "@patternfly/eslint-config-elements", - "overrides": [ - { - "files": [ - "./tools/create-element/**/*" - ], - "rules": { - "no-console": "off" - } - } - ] -} diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index d55f9c2649..7d6949adad 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '20' cache: npm - name: Bundle diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 64543539cf..915c5b4e41 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -13,7 +13,7 @@ jobs: uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '20' cache: npm - name: Verify JSPM URL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 658389c19d..0685fc84c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '20' cache: npm - run: npm ci --prefer-offline diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2fcacb44ac..d00b424a18 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,9 +40,8 @@ jobs: # steps: # - name: Debugging context variables # run: echo "$GITHUB_CONTEXT" - - test: - name: Run test suite (Web Test Runner) + lint: + name: Lint files runs-on: ubuntu-latest steps: - name: Checkout repository @@ -52,7 +51,7 @@ jobs: - name: Configure node version uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '20' cache: npm - name: Install dependencies @@ -62,13 +61,32 @@ jobs: id: lint run: npm run lint + + test: + name: Run test suite (Web Test Runner) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Configures the node version used on GitHub-hosted runners + - name: Configure node version + uses: actions/setup-node@v3 + with: + node-version: '20' + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline + + - name: install playwright + run: npx playwright install + - name: Run tests run: npm test - if: ${{ always() }} - name: JUnit Report Action uses: mikepenz/action-junit-report@v2.8.2 - if: ${{ always() }} with: report_paths: test-results/test-results.xml fail_on_failure: true # fail the actions run if the tests failed @@ -79,10 +97,8 @@ jobs: strategy: matrix: node: - - '18' - - '19' - # https://github.com/TypeStrong/ts-node/issues/1997 - # - '20' + - '20' + - '21' if: | github.event_name == 'workflow_dispatch' || github.event_name == 'push' @@ -126,8 +142,9 @@ jobs: validate: name: Validate successful build on main needs: - - build + - lint - test + - build if: ${{ always() }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 36bac55fca..db55b2b814 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18' + node-version: '20' cache: npm - run: npm ci --prefer-offline diff --git a/.gitignore b/.gitignore index 7be9821d53..a0146b7ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -65,12 +65,12 @@ tools/*/test/**/*.png !core/*/demo/* !tools/*/demo/* -elements/pf-icon/icons !elements/pf-icon/demo/icons/**/*.js !scripts/**/*.js !tools/eslint-plugin/index.js +!tools/eslint-config/**/*.js *.tgz custom-elements.json diff --git a/.husky/commit-msg b/.husky/commit-msg index 3b4299ef30..da9948310e 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - npx --no -- commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit index a926c364e6..2312dc587f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1 @@ -#!/bin/sh npx lint-staged diff --git a/.nvmrc b/.nvmrc index e44a38e080..790e1105f2 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.12.1 +v20.10.0 diff --git a/brand/logo/svg/pfe-icon-white.svg b/brand/logo/svg/pfe-icon-white.svg index e41dfb71bb..d6fdd085c1 100644 --- a/brand/logo/svg/pfe-icon-white.svg +++ b/brand/logo/svg/pfe-icon-white.svg @@ -1 +1,7 @@ -PatternFly Elements Icon White \ No newline at end of file + + PatternFly Elements Icon White + + + + + diff --git a/core/pfe-core/CHANGELOG.md b/core/pfe-core/CHANGELOG.md index 9205fa22a5..ce34e76003 100644 --- a/core/pfe-core/CHANGELOG.md +++ b/core/pfe-core/CHANGELOG.md @@ -1,5 +1,128 @@ # @patternfly/pfe-core +## 3.0.0 + +### Major Changes + +- 1d89f73: `RovingTabindexController`: deprecate the `initItems` method and add `getItems` and `getItemContainer` instead + + BEFORE: + + ```ts + #tabindex = new RovingTabindexController(this); + constructor() { + super(); + this.#tabindex.initItems(this.#items); + } + ``` + + AFTER: + + ```ts + #tabindex = new RovingTabindexController(this, { + getItems: () => this.#items, + }); + ``` + +- 3766961: `@cascades`: deprecated `@cascades` decorator and `CascadeController`. Use context instead. + + **BEFORE**: The element in charge of the context cascades data down to + specifically named children. + + ```ts + import { LitElement } from "lit"; + import { property } from "lit/decorators/property.js"; + import { cascades } from "@patternfly/pfe-core/decorators/cascades.js"; + + class MyMood extends LitElement { + @cascades("my-eyes", "my-mouth") + @property() + mood: "happy" | "sad" | "mad" | "glad"; + } + ``` + + **AFTER**: children subscribe to updates from the context provider. + + ```ts + import { LitElement } from "lit"; + import { property } from "lit/decorators/property.js"; + import { provide } from "@lit/context"; + import { createContextWithRoot } from "@patternfly/pfe-core/functions/context.js"; + + export type Mood = "happy" | "sad" | "mad" | "glad"; + + export const moodContext = createContextWithRoot(Symbol("mood")); + + class MyMood extends LitElement { + @provide({ context: moodContext }) + @property() + mood: Mood; + } + ``` + + ```ts + import { LitElement } from "lit"; + import { property } from "lit/decorators/property.js"; + import { consume } from "@lit/context"; + import { moodContext, type Mood } from "./my-mood.js"; + + class MyEyes extends LitElement { + @consume({ context: moodContext, subscribe: true }) + @state() + private mood: Mood; + } + ``` + +- 0d92145: `InternalsController`: made the constructor private. Use `InternalsController.of` + + BEFORE: + + ```js + class PfJazzHands extends LitElement { + #internals = new InternalsController(this); + } + ``` + + AFTER: + + ```js + class PfJazzHands extends LitElement { + #internals = InternalsController.of(this); + } + ``` + +- de4cfa4: Remove `deprecatedCustomEvent` + +### Minor Changes + +- ac0c376: `SlotController`: Add `isEmpty` method to check if a slot is empty. If no slot name is provided it will check the default slot. (#2603) + `SlotController`: `hasSlotted` method now returns default slot if no slot name is provided. (#2603) +- d4e5411: **Context**: added `createContextWithRoot`. Use this when creating contexts that + are shared with child elements. +- c71bbe5: `InternalsController`: added `computedLabelText` read-only property +- c71bbe5: `InternalsController`: reflect all methods and properties from `ElementInternals` +- fa50164: `Logger`: loosen the type of allowed controller hosts +- fa50164: `OverflowController`: recalculate overflow when the window size changes and when tabs are dynamically created. +- 0d92145: `RovingTabindexController`: keyboard navigation includes first-character navigation. +- fa50164: `TabsAriaController`: Added TabsAriaController, used to manage the accesibility tree for tabs and panels. + + ```ts + #tabs = new TabsAriaController(this, { + isTab: (x: Node): x is PfTab => x instanceof PfTab, + isPanel: (x: Node): x is PfTabPanel => x instanceof PfTabPanel, + }); + ``` + + Please review the [Tabs 2.4 to 3.0 migration guide](https://patternflyelements.org/migration/3.0/tabs) for more + information. + +### Patch Changes + +- 24d43bd: `Logger`: add `Logger.info` and `Logger.debug` +- e62244f: `InternalsController`: added missing `ariaDescription` defined by ARIAMixin +- 24d43bd: `SlotController`: move debug logs to `Logger.debug` +- 50f462c: Update dependencies, including Lit version 3 + ## 2.4.1 ### Patch Changes diff --git a/core/pfe-core/README.md b/core/pfe-core/README.md index 2a752a7d3a..1a69102ade 100644 --- a/core/pfe-core/README.md +++ b/core/pfe-core/README.md @@ -30,5 +30,4 @@ Utilities for building PatternFly elements. ## Functions - `debounce` - debounce a function -- `deprecatedCustomEvent` - create (deprecated) composed `CustomEvent`s - `getRandomId` - generate a random element ID, optionally with a given prefix diff --git a/core/pfe-core/controllers/cascade-controller.ts b/core/pfe-core/controllers/cascade-controller.ts index 4df9c15ddc..c7a285c033 100644 --- a/core/pfe-core/controllers/cascade-controller.ts +++ b/core/pfe-core/controllers/cascade-controller.ts @@ -4,17 +4,23 @@ import { bound } from '../decorators/bound.js'; import { debounce } from '../functions/debounce.js'; import { Logger } from './logger.js'; +/** + * @deprecated: use context, especially via `@patternfly/pfe-core/functions/context.js`; + */ export interface Options { properties: Partial>; prefix?: string; } +/** + * @deprecated: use context, especially via `@patternfly/pfe-core/functions/context.js`; + */ export class CascadeController implements ReactiveController { private class: typeof ReactiveElement; private logger: Logger; - static instances: WeakMap> = new WeakMap(); + static instances = new WeakMap>(); mo = new MutationObserver(this.parse); diff --git a/core/pfe-core/controllers/css-variable-controller.ts b/core/pfe-core/controllers/css-variable-controller.ts index bd2d9e54dd..4070f5a57e 100644 --- a/core/pfe-core/controllers/css-variable-controller.ts +++ b/core/pfe-core/controllers/css-variable-controller.ts @@ -15,5 +15,5 @@ export class CssVariableController implements ReactiveController { return this.style.getPropertyValue(this.parseProperty(name)).trim() || null; } - hostConnected?(): void + hostConnected?(): void; } diff --git a/core/pfe-core/controllers/floating-dom-controller.ts b/core/pfe-core/controllers/floating-dom-controller.ts index d794d153f7..be84b89fed 100644 --- a/core/pfe-core/controllers/floating-dom-controller.ts +++ b/core/pfe-core/controllers/floating-dom-controller.ts @@ -1,5 +1,5 @@ import type { Placement } from '@floating-ui/dom'; -import type { ReactiveController, ReactiveElement } from 'lit'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; import type { StyleInfo } from 'lit/directives/style-map.js'; import type { OffsetOptions as Offset } from '@floating-ui/core'; @@ -11,7 +11,7 @@ import { offset as offsetMiddleware, shift as shiftMiddleware, flip as flipMiddleware, - arrow as arrowMiddleware + arrow as arrowMiddleware, } from '@floating-ui/dom'; type Lazy = T | (() => T | null | undefined); @@ -46,7 +46,7 @@ export class FloatingDOMController implements ReactiveController { #alignment?: Alignment; #styles?: StyleInfo; #placement?: Placement; - #options: Required; + #options: FloatingDOMControllerOptions; get #invoker() { const { invoker } = this.#options; @@ -95,20 +95,27 @@ export class FloatingDOMController implements ReactiveController { } constructor( - private host: ReactiveElement, + private host: ReactiveControllerHost, options: FloatingDOMControllerOptions ) { host.addController(this); - this.#options = options as Required; - this.#options.invoker ??= host; - this.#options.shift ??= true; + this.#options = { + invoker: (host instanceof HTMLElement ? () => host : () => undefined), + shift: true, + ...options, + }; } hostDisconnected() { this.#cleanup?.(); } - async #update(placement: Placement = 'top', offset?: Offset, flip = true, fallbackPlacements?: Placement[]) { + async #update( + placement: Placement = 'top', + offset?: Offset, + flip = true, + fallbackPlacements?: Placement[], + ) { const { padding, shift } = this.#options; const invoker = this.#invoker; @@ -117,7 +124,12 @@ export class FloatingDOMController implements ReactiveController { if (!invoker || !content) { return; } - const { x, y, placement: _placement, middlewareData } = await computePosition(invoker, content, { + const { + x, + y, + placement: _placement, + middlewareData, + } = await computePosition(invoker, content, { strategy: 'absolute', placement, middleware: [ @@ -125,7 +137,7 @@ export class FloatingDOMController implements ReactiveController { shift && shiftMiddleware({ padding }), arrow && arrowMiddleware({ element: arrow, padding: arrow.offsetHeight / 2 }), flip && flipMiddleware({ padding, fallbackPlacements }), - ].filter(Boolean) + ].filter(Boolean), }); if (arrow) { diff --git a/core/pfe-core/controllers/internals-controller.ts b/core/pfe-core/controllers/internals-controller.ts index a7be52c2d3..3ebdbd842a 100644 --- a/core/pfe-core/controllers/internals-controller.ts +++ b/core/pfe-core/controllers/internals-controller.ts @@ -4,124 +4,257 @@ function isARIAMixinProp(key: string): key is keyof ARIAMixin { return key === 'role' || key.startsWith('aria'); } +type FACE = HTMLElement & { + formDisabledCallback?(disabled: boolean): void; +}; + +const protos = new WeakMap(); + +let constructingAllowed = false; + +interface InternalsControllerOptions extends Partial { + getHTMLElement?(): HTMLElement; +} + +/** reactively forward the internals object's aria mixin prototype */ +function aria( + target: InternalsController, + key: keyof InternalsController, +) { + if (!protos.has(target)) { + protos.set(target, new Set()); + } + if (protos.get(target).has(key)) { + return; + } + if (!isARIAMixinProp(key)) { + throw new Error('@aria can only be called on ARIAMixin properties'); + } + // typescript experimental decorator + Object.defineProperty(target, key, { + enumerable: true, + configurable: false, + get(this: InternalsController) { + // @ts-expect-error: because i'm bad, i'm bad + return this.attach()[key]; + }, + set(this: InternalsController, value: string | null) { + // @ts-expect-error: shamone! + this.attach()[key] = value; + this.host.requestUpdate(); + }, + }); + protos.get(target).add(key); +} + +function getLabelText(label: HTMLElement) { + if (label.hidden) { + return ''; + } else { + const ariaLabel = label.getAttribute?.('aria-label'); + return ariaLabel ?? label.textContent; + } +} + export class InternalsController implements ReactiveController, ARIAMixin { - declare role: ARIAMixin['role']; - declare ariaAtomic: ARIAMixin['ariaAtomic']; - declare ariaAutoComplete: ARIAMixin['ariaAutoComplete']; - declare ariaBusy: ARIAMixin['ariaBusy']; - declare ariaChecked: ARIAMixin['ariaChecked']; - declare ariaColCount: ARIAMixin['ariaColCount']; - declare ariaColIndex: ARIAMixin['ariaColIndex']; - declare ariaColIndexText: string | null; - declare ariaColSpan: ARIAMixin['ariaColSpan']; - declare ariaCurrent: ARIAMixin['ariaCurrent']; - declare ariaDisabled: ARIAMixin['ariaDisabled']; - declare ariaExpanded: ARIAMixin['ariaExpanded']; - declare ariaHasPopup: ARIAMixin['ariaHasPopup']; - declare ariaHidden: ARIAMixin['ariaHidden']; - declare ariaInvalid: ARIAMixin['ariaInvalid']; - declare ariaKeyShortcuts: ARIAMixin['ariaKeyShortcuts']; - declare ariaLabel: ARIAMixin['ariaLabel']; - declare ariaLevel: ARIAMixin['ariaLevel']; - declare ariaLive: ARIAMixin['ariaLive']; - declare ariaModal: ARIAMixin['ariaModal']; - declare ariaMultiLine: ARIAMixin['ariaMultiLine']; - declare ariaMultiSelectable: ARIAMixin['ariaMultiSelectable']; - declare ariaOrientation: ARIAMixin['ariaOrientation']; - declare ariaPlaceholder: ARIAMixin['ariaPlaceholder']; - declare ariaPosInSet: ARIAMixin['ariaPosInSet']; - declare ariaPressed: ARIAMixin['ariaPressed']; - declare ariaReadOnly: ARIAMixin['ariaReadOnly']; - declare ariaRequired: ARIAMixin['ariaRequired']; - declare ariaRoleDescription: ARIAMixin['ariaRoleDescription']; - declare ariaRowCount: ARIAMixin['ariaRowCount']; - declare ariaRowIndex: ARIAMixin['ariaRowIndex']; - declare ariaRowIndexText: string | null; - declare ariaRowSpan: ARIAMixin['ariaRowSpan']; - declare ariaSelected: ARIAMixin['ariaSelected']; - declare ariaSetSize: ARIAMixin['ariaSetSize']; - declare ariaSort: ARIAMixin['ariaSort']; - declare ariaValueMax: ARIAMixin['ariaValueMax']; - declare ariaValueMin: ARIAMixin['ariaValueMin']; - declare ariaValueNow: ARIAMixin['ariaValueNow']; - declare ariaValueText: ARIAMixin['ariaValueText']; - - #internals: ElementInternals; - - #formDisabled = false; + private static instances = new WeakMap(); + + declare readonly form: ElementInternals['form']; + declare readonly shadowRoot: ElementInternals['shadowRoot']; + + // https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/states + declare readonly states: unknown; + declare readonly willValidate: ElementInternals['willValidate']; + declare readonly validationMessage: ElementInternals['validationMessage']; + + public static of( + host: ReactiveControllerHost, + options?: InternalsControllerOptions, + ): InternalsController { + constructingAllowed = true; + // implement the singleton pattern + // using a public static constructor method is much easier to manage, + // due to the quirks of our typescript config + const instance: InternalsController = + InternalsController.instances.get(host) + ?? new InternalsController(host, options); + instance.initializeOptions(options); + constructingAllowed = false; + return instance; + } + + @aria role: string | null = null; + + @aria ariaActivedescendant: string | null = null; + @aria ariaAtomic: string | null = null; + @aria ariaAutoComplete: string | null = null; + @aria ariaBusy: string | null = null; + @aria ariaChecked: string | null = null; + @aria ariaColCount: string | null = null; + @aria ariaColIndex: string | null = null; + @aria ariaColIndexText: string | null = null; + @aria ariaColSpan: string | null = null; + @aria ariaCurrent: string | null = null; + @aria ariaDescription: string | null = null; + @aria ariaDisabled: string | null = null; + @aria ariaExpanded: string | null = null; + @aria ariaHasPopup: string | null = null; + @aria ariaHidden: string | null = null; + @aria ariaInvalid: string | null = null; + @aria ariaKeyShortcuts: string | null = null; + @aria ariaLabel: string | null = null; + @aria ariaLevel: string | null = null; + @aria ariaLive: string | null = null; + @aria ariaModal: string | null = null; + @aria ariaMultiLine: string | null = null; + @aria ariaMultiSelectable: string | null = null; + @aria ariaOrientation: string | null = null; + @aria ariaPlaceholder: string | null = null; + @aria ariaPosInSet: string | null = null; + @aria ariaPressed: string | null = null; + @aria ariaReadOnly: string | null = null; + @aria ariaRequired: string | null = null; + @aria ariaRoleDescription: string | null = null; + @aria ariaRowCount: string | null = null; + @aria ariaRowIndex: string | null = null; + @aria ariaRowIndexText: string | null = null; + @aria ariaRowSpan: string | null = null; + @aria ariaSelected: string | null = null; + @aria ariaSetSize: string | null = null; + @aria ariaSort: string | null = null; + @aria ariaValueMax: string | null = null; + @aria ariaValueMin: string | null = null; + @aria ariaValueNow: string | null = null; + @aria ariaValueText: string | null = null; + + /** WARNING: be careful of cross-root ARIA browser support */ + @aria ariaActiveDescendantElement: Element | null = null; + /** WARNING: be careful of cross-root ARIA browser support */ + @aria ariaControlsElements: Element | null = null; + /** WARNING: be careful of cross-root ARIA browser support */ + @aria ariaDescribedByElements: Element | null = null; + /** WARNING: be careful of cross-root ARIA browser support */ + @aria ariaDetailsElements: Element | null = null; + /** WARNING: be careful of cross-root ARIA browser support */ + @aria ariaErrorMessageElements: Element | null = null; + /** WARNING: be careful of cross-root ARIA browser support */ + @aria ariaFlowToElements: Element | null = null; + /** WARNING: be careful of cross-root ARIA browser support */ + @aria ariaLabelledByElements: Element | null = null; + /** WARNING: be careful of cross-root ARIA browser support */ + @aria ariaOwnsElements: Element | null = null; /** True when the control is disabled via it's containing fieldset element */ get formDisabled() { - return this.host.matches(':disabled') || this.#formDisabled; + return this.element?.matches(':disabled') || this._formDisabled; } - static protos = new WeakMap(); - get labels() { - return this.#internals.labels; + return this.internals.labels; } get validity() { - return this.#internals.validity; + return this.internals.validity; } - constructor( - public host: ReactiveControllerHost & HTMLElement, - options?: Partial + /** A best-attempt based on observed behaviour in FireFox 115 on fedora 38 */ + get computedLabelText() { + return this.internals.ariaLabel + || Array.from(this.internals.labels as NodeListOf) + .reduce((acc, label) => + `${acc}${getLabelText(label)}`, ''); + } + + private get element() { + return this.host instanceof HTMLElement ? this.host : this.options?.getHTMLElement?.(); + } + + private internals!: ElementInternals; + + private _formDisabled = false; + + private constructor( + public host: ReactiveControllerHost, + private options?: InternalsControllerOptions, ) { - this.#internals = host.attachInternals(); + if (!constructingAllowed) { + throw new Error('InternalsController must be constructed with `InternalsController.for()`'); + } + if (!this.element) { + throw new Error( + `InternalsController must be instantiated with an HTMLElement or a \`getHTMLElement\` function`, + ); + } + this.attach(); + this.initializeOptions(options); + InternalsController.instances.set(host, this); + this.#polyfillDisabledPseudo(); + } + + /** + * We need to polyfill :disabled + * see https://github.com/calebdwilliams/element-internals-polyfill/issues/88 + */ + #polyfillDisabledPseudo() { + // START polyfill-disabled // We need to polyfill :disabled // see https://github.com/calebdwilliams/element-internals-polyfill/issues/88 - const orig = (host as HTMLElement & { formDisabledCallback?(disabled: boolean): void }).formDisabledCallback; - (host as HTMLElement & { formDisabledCallback?(disabled: boolean): void }).formDisabledCallback = disabled => { - this.#formDisabled = disabled; - orig?.call(host, disabled); + const orig = (this.element as FACE).formDisabledCallback; + (this.element as FACE).formDisabledCallback = disabled => { + this._formDisabled = disabled; + orig?.call(this.host, disabled); + // END polyfill-disabled }; - // proxy the internals object's aria prototype - for (const key of Object.keys(Object.getPrototypeOf(this.#internals))) { - if (isARIAMixinProp(key)) { - Object.defineProperty(this, key, { - get() { - return this.#internals[key]; - }, - set(value) { - this.#internals[key] = value; - this.host.requestUpdate(); - } - }); - } - } + } + + /** + * Typescript (with experimental decorators) will compile the class + * such that the order of operations is: + * 1. set up constructor parameter fields + * 2. run decorated field setters with initializers as the value + * 3. run the rest of the constructor + * Because of that, `this.internals` may not be available in the decorator setter + * so we cheat here with nullish coalescing assignment operator `??=`; + */ + private attach() { + this.internals ??= this.element!.attachInternals(); + return this.internals; + } - for (const [key, val] of Object.entries(options ?? {})) { + private initializeOptions(options?: Partial) { + this.options ??= options ?? {}; + const { getHTMLElement, ...aria } = this.options; + this.options.getHTMLElement ??= getHTMLElement; + for (const [key, val] of Object.entries(aria)) { if (isARIAMixinProp(key)) { this[key] = val; } } } - hostConnected?(): void + hostConnected?(): void; setFormValue(...args: Parameters) { - return this.#internals.setFormValue(...args); + return this.internals.setFormValue(...args); } setValidity(...args: Parameters) { - return this.#internals.setValidity(...args); + return this.internals.setValidity(...args); } checkValidity(...args: Parameters) { - return this.#internals.checkValidity(...args); + return this.internals.checkValidity(...args); } reportValidity(...args: Parameters) { - return this.#internals.reportValidity(...args); + return this.internals.reportValidity(...args); } submit() { - this.#internals.form?.requestSubmit(); + this.internals.form?.requestSubmit(); } reset() { - this.#internals.form?.reset(); + this.internals.form?.reset(); } } diff --git a/core/pfe-core/controllers/light-dom-controller.ts b/core/pfe-core/controllers/light-dom-controller.ts index a34c4d2e6c..3838180431 100644 --- a/core/pfe-core/controllers/light-dom-controller.ts +++ b/core/pfe-core/controllers/light-dom-controller.ts @@ -49,8 +49,8 @@ export class LightDOMController implements ReactiveController { */ hasLightDOM(): boolean { return !!( - this.host.children.length > 0 || - (this.host.textContent ?? '').trim().length > 0 + this.host.children.length > 0 + || (this.host.textContent ?? '').trim().length > 0 ); } } diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts new file mode 100644 index 0000000000..3dc85192f1 --- /dev/null +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -0,0 +1,343 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +export interface ListboxAccessibilityController< + Item extends HTMLElement +> extends ReactiveController { + items: Item[]; + activeItem?: Item; + nextItem?: Item; + prevItem?: Item; + firstItem?: Item; + lastItem?: Item; + updateItems(items: Item[]): void; + setActiveItem(item: Item): void; +} + +/** + * Filtering, multiselect, and orientation options for listbox + */ +export interface ListboxConfigOptions { + multi?: boolean; + a11yController: ListboxAccessibilityController; + getHTMLElement(): HTMLElement | null; + requestSelect(option: T, force?: boolean): boolean; + isSelected(option: T): boolean; +} + +let constructingAllowed = false; + +/** + * Implements listbox semantics and accesibility. As there are two recognized + * patterns for implementing keyboard interactions with listbox patterns, + * provide a secondary controller (either RovingTabindexController or + * ActiveDescendantController) to complete the implementation. + */ +export class ListboxController implements ReactiveController { + private static instances = new WeakMap>(); + + public static of( + host: ReactiveControllerHost, + options: ListboxConfigOptions, + ): ListboxController { + constructingAllowed = true; + const instance: ListboxController = + ListboxController.instances.get(host) ?? new ListboxController(host, options); + constructingAllowed = false; + return instance; + } + + private constructor( + public host: ReactiveControllerHost, + // this should ideally be ecma #private, but tsc/esbuild tooling isn't up to scratch yet + // so for now we rely on the underscore convention to avoid compile-time errors + // try refactoring after updating tooling dependencies + private _options: ListboxConfigOptions, + ) { + if (!constructingAllowed) { + throw new Error('ListboxController must be constructed with `ListboxController.of()`'); + } + if (!(host instanceof HTMLElement) && typeof _options.getHTMLElement !== 'function') { + throw new Error( + `ListboxController requires the host to be an HTMLElement, or for the initializer to include a \`getHTMLElement()\` function`, + ); + } + if (!_options.a11yController) { + throw new Error( + `ListboxController requires an additional keyboard accessibility controller. Provide either a RovingTabindexController or an ActiveDescendantController`, + ); + } + ListboxController.instances.set(host, this); + this.host.addController(this); + if (this.element?.isConnected) { + this.hostConnected(); + } + } + + /** Current active descendant when shift key is pressed */ + #shiftStartingItem: Item | null = null; + + /** All options that will not be hidden by a filter */ + #items: Item[] = []; + + #listening = false; + + /** Whether listbox is disabled */ + disabled = false; + + /** Current active descendant in listbox */ + get activeItem() { + return this.options.find(option => + option === this._options.a11yController.activeItem) || this._options.a11yController.firstItem; + } + + get nextItem() { + return this._options.a11yController.nextItem; + } + + get options() { + return this.#items; + } + + /** + * array of options which are selected + */ + get selectedOptions() { + return this.options.filter(option => this._options.isSelected(option)); + } + + get value() { + const [firstItem] = this.selectedOptions; + return this._options.multi ? this.selectedOptions : firstItem; + } + + private get element() { + return this._options.getHTMLElement(); + } + + async hostConnected() { + if (!this.#listening) { + await this.host.updateComplete; + this.element?.addEventListener('click', this.#onClick); + this.element?.addEventListener('focus', this.#onFocus); + this.element?.addEventListener('keydown', this.#onKeydown); + this.element?.addEventListener('keyup', this.#onKeyup); + this.#listening = true; + } + } + + hostUpdated() { + this.element?.setAttribute('role', 'listbox'); + this.element?.setAttribute('aria-disabled', String(!!this.disabled)); + this.element?.setAttribute('aria-multi-selectable', String(!!this._options.multi)); + for (const option of this._options.a11yController.items) { + if (this._options.a11yController.activeItem === option) { + option.setAttribute('aria-selected', 'true'); + } else { + option.removeAttribute('aria-selected'); + } + } + } + + hostDisconnected() { + this.element?.removeEventListener('click', this.#onClick); + this.element?.removeEventListener('focus', this.#onFocus); + this.element?.removeEventListener('keydown', this.#onKeydown); + this.element?.removeEventListener('keyup', this.#onKeyup); + this.#listening = false; + } + + #getEnabledOptions(options = this.options) { + return options.filter(option => !option.ariaDisabled && !option.closest('[disabled]')); + } + + #getEventOption(event: Event): Item | undefined { + return event + .composedPath() + .find(node => this.#items.includes(node as Item)) as Item | undefined; + } + + + /** + * handles focusing on an option: + * updates roving tabindex and active descendant + */ + #onFocus = (event: FocusEvent) => { + const target = this.#getEventOption(event); + if (target && target !== this._options.a11yController.activeItem) { + this._options.a11yController.setActiveItem(target); + } + }; + + /** + * handles clicking on a listbox option: + * which selects an item by default + * or toggles selection if multiselectable + */ + #onClick = (event: MouseEvent) => { + const target = this.#getEventOption(event); + if (target) { + const oldValue = this.value; + if (this._options.multi) { + if (!event.shiftKey) { + this._options.requestSelect(target, !this._options.isSelected(target)); + } else if (this.#shiftStartingItem && target) { + this.#updateMultiselect(target, this.#shiftStartingItem); + } + } else { + // select target and deselect all other options + this.options.forEach(option => this._options.requestSelect(option, option === target)); + } + if (target !== this._options.a11yController.activeItem) { + this._options.a11yController.setActiveItem(target); + } + if (oldValue !== this.value) { + this.host.requestUpdate(); + } + } + }; + + /** + * handles keyup: + * track whether shift key is being used for multiselectable listbox + */ + #onKeyup = (event: KeyboardEvent) => { + const target = this.#getEventOption(event); + if (target && event.shiftKey && this._options.multi) { + if (this.#shiftStartingItem && target) { + this.#updateMultiselect(target, this.#shiftStartingItem); + } + if (event.key === 'Shift') { + this.#shiftStartingItem = null; + } + } + }; + + /** + * handles keydown: + * filters listbox by keyboard event when slotted option has focus, + * or by external element such as a text field + */ + #onKeydown = (event: KeyboardEvent) => { + const target = this.#getEventOption(event); + + if (!target || event.altKey || event.metaKey || !this.options.includes(target)) { + return; + } + + const first = this._options.a11yController.firstItem; + const last = this._options.a11yController.lastItem; + + // need to set for keyboard support of multiselect + if (event.key === 'Shift' && this._options.multi) { + this.#shiftStartingItem = this.activeItem ?? null; + } + + switch (event.key) { + case 'a': + case 'A': + if (event.ctrlKey) { + // ctrl+A selects all options + this.#updateMultiselect(first, last, true); + event.preventDefault(); + } + break; + case 'Enter': + case ' ': + // enter and space are only applicable if a listbox option is clicked + // an external text input should not trigger multiselect + if (this._options.multi) { + if (event.shiftKey) { + this.#updateMultiselect(target); + } else if (!this.disabled) { + this._options.requestSelect(target, !this._options.isSelected(target)); + } + } else { + this.#updateSingleselect(); + } + event.preventDefault(); + break; + default: + break; + } + }; + + /** + * handles change to options given previous options array + */ + #optionsChanged(oldOptions: Item[]) { + const setSize = this.#items.length; + if (setSize !== oldOptions.length + || !oldOptions.every((element, index) => element === this.#items[index])) { + this._options.a11yController.updateItems(this.options); + } + } + + /** + * updates option selections for single select listbox + */ + #updateSingleselect() { + if (!this._options.multi && !this.disabled) { + this.#getEnabledOptions() + .forEach(option => + this._options.requestSelect( + option, + option === this._options.a11yController.activeItem, + )); + } + } + + /** + * updates option selections for multiselectable listbox: + * toggles all options between active descendant and target + */ + #updateMultiselect( + currentItem?: Item, + referenceItem = this.activeItem, + ctrlA = false, + ) { + if (referenceItem && this._options.multi && !this.disabled && currentItem) { + // select all options between active descendant and target + const [start, end] = [ + this.options.indexOf(referenceItem), + this.options.indexOf(currentItem), + ].sort(); + const options = [...this.options].slice(start, end + 1); + + // by default CTRL+A will select all options + // if all options are selected, CTRL+A will deselect all options + const allSelected = this.#getEnabledOptions(options) + .filter(option => !this._options.isSelected(option))?.length === 0; + + // whether options will be selected (true) or deselected (false) + const selected = ctrlA ? !allSelected : this._options.isSelected(referenceItem); + this.#getEnabledOptions(options).forEach(option => + this._options.requestSelect(option, selected)); + + // update starting item for other multiselect + this.#shiftStartingItem = currentItem; + } + } + + /** + * sets the listbox value based on selected options + */ + setValue(value: Item | Item[]) { + const selected = Array.isArray(value) ? value : [value]; + const [firstItem = null] = selected; + for (const option of this.options) { + this._options.requestSelect(option, ( + !!this._options.multi && Array.isArray(value) ? value?.includes(option) + : firstItem === option + )); + } + } + + /** + * register's the host's Item elements as listbox controller items + */ + setOptions(options: Item[]) { + const oldOptions = [...this.#items]; + this.#items = options; + this.#optionsChanged(oldOptions); + } +} diff --git a/core/pfe-core/controllers/logger.ts b/core/pfe-core/controllers/logger.ts index 0d726d1f62..ec3f313a96 100644 --- a/core/pfe-core/controllers/logger.ts +++ b/core/pfe-core/controllers/logger.ts @@ -1,12 +1,16 @@ -import type { ReactiveController, ReactiveElement } from 'lit'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; export class Logger implements ReactiveController { private static logDebug: boolean; - private static instances: WeakMap = new WeakMap(); + private static instances = new WeakMap(); private get prefix() { - return `[${this.host.localName}${this.host.id ? `#${this.host.id}` : ''}]`; + if (this.host instanceof HTMLElement) { + return `[${this.host.localName}${this.host.id ? `#${this.host.id}` : ''}]`; + } else { + return `[${this.host.constructor.name}]`; + } } /** @@ -33,7 +37,6 @@ export class Logger implements ReactiveController { /** * A logging wrapper which checks the debugLog boolean and prints to the console if true. - * * @example Logger.debug("Hello"); */ static debug(...msgs: unknown[]) { @@ -44,7 +47,6 @@ export class Logger implements ReactiveController { /** * A logging wrapper which checks the debugLog boolean and prints to the console if true. - * * @example Logger.info("Hello"); */ static info(...msgs: unknown[]) { @@ -55,7 +57,6 @@ export class Logger implements ReactiveController { /** * A logging wrapper which checks the debugLog boolean and prints to the console if true. - * * @example Logger.log("Hello"); */ static log(...msgs: unknown[]) { @@ -66,7 +67,6 @@ export class Logger implements ReactiveController { /** * A console warning wrapper which formats your output with useful debugging information. - * * @example Logger.warn("Hello"); */ static warn(...msgs: unknown[]) { @@ -86,7 +86,6 @@ export class Logger implements ReactiveController { /** * Debug logging that outputs the tag name as a prefix automatically - * * @example this.logger.log("Hello"); */ debug(...msgs: unknown[]) { @@ -95,7 +94,6 @@ export class Logger implements ReactiveController { /** * Info logging that outputs the tag name as a prefix automatically - * * @example this.logger.log("Hello"); */ info(...msgs: unknown[]) { @@ -104,7 +102,6 @@ export class Logger implements ReactiveController { /** * Local logging that outputs the tag name as a prefix automatically - * * @example this.logger.log("Hello"); */ log(...msgs: unknown[]) { @@ -129,7 +126,7 @@ export class Logger implements ReactiveController { Logger.error(this.prefix, ...msgs); } - constructor(private host: ReactiveElement) { + constructor(private host: ReactiveControllerHost) { // We only need one logger instance per host if (Logger.instances.get(host)) { return Logger.instances.get(host) as Logger; diff --git a/core/pfe-core/controllers/overflow-controller.ts b/core/pfe-core/controllers/overflow-controller.ts index 62899f0f35..5d4b888497 100644 --- a/core/pfe-core/controllers/overflow-controller.ts +++ b/core/pfe-core/controllers/overflow-controller.ts @@ -1,22 +1,53 @@ -import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { ReactiveController, ReactiveElement } from 'lit'; import { isElementInView } from '@patternfly/pfe-core/functions/isElementInView.js'; export interface Options { + /** + * Force hide the scroll buttons regardless of overflow + */ hideOverflowButtons?: boolean; + /** + * Delay in ms to wait before checking for overflow + */ + scrollTimeoutDelay?: number; } export class OverflowController implements ReactiveController { + static #instances = new Set(); + + static { + // on resize check for overflows to add or remove scroll buttons + window.addEventListener('resize', () => { + for (const instance of this.#instances) { + instance.onScroll(); + } + }, { capture: false, passive: true }); + } + /** Overflow container */ #container?: HTMLElement; /** Children that can overflow */ #items: HTMLElement[] = []; - #scrollTimeoutDelay = 0; + #scrollTimeoutDelay: number; #scrollTimeout?: ReturnType; /** Default state */ - #hideOverflowButtons = false; + #hideOverflowButtons: boolean; + + #mo = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + this.#setOverflowState(); + } + } + }); + + #ro = new ResizeObserver(() => { + this.#setOverflowState(); + }); + showScrollButtons = false; overflowLeft = false; overflowRight = false; @@ -29,10 +60,19 @@ export class OverflowController implements ReactiveController { return this.#items.at(-1); } - constructor(public host: ReactiveControllerHost & Element, private options?: Options) { - this.host.addController(this); - if (options?.hideOverflowButtons) { - this.#hideOverflowButtons = options?.hideOverflowButtons; + constructor( + // TODO: widen this type to ReactiveControllerHost + public host: ReactiveElement, + private options?: Options, + ) { + this.#hideOverflowButtons = options?.hideOverflowButtons ?? false; + this.#scrollTimeoutDelay = options?.scrollTimeoutDelay ?? 0; + if (host.isConnected) { + OverflowController.#instances.add(this); + } + host.addController(this); + if (host.isConnected) { + this.hostConnected(); } } @@ -40,15 +80,26 @@ export class OverflowController implements ReactiveController { if (!this.firstItem || !this.lastItem || !this.#container) { return; } - this.overflowLeft = !this.#hideOverflowButtons && !isElementInView(this.#container, this.firstItem); - this.overflowRight = !this.#hideOverflowButtons && !isElementInView(this.#container, this.lastItem); + const prevLeft = this.overflowLeft; + const prevRight = this.overflowRight; + + this.overflowLeft = !this.#hideOverflowButtons + && !isElementInView(this.#container, this.firstItem); + this.overflowRight = !this.#hideOverflowButtons + && !isElementInView(this.#container, this.lastItem); let scrollButtonsWidth = 0; if (this.overflowLeft || this.overflowRight) { - scrollButtonsWidth = (this.#container.parentElement?.querySelector('button')?.getBoundingClientRect().width || 0) * 2; + scrollButtonsWidth = + (this.#container.parentElement?.querySelector('button')?.getBoundingClientRect().width || 0) + * 2; + } + this.showScrollButtons = !this.#hideOverflowButtons + && this.#container.scrollWidth > (this.#container.clientWidth + scrollButtonsWidth); + + // only request update if there has been a change + if ((prevLeft !== this.overflowLeft) || (prevRight !== this.overflowRight)) { + this.host.requestUpdate(); } - this.showScrollButtons = !this.#hideOverflowButtons && - this.#container.scrollWidth > (this.#container.clientWidth + scrollButtonsWidth); - this.host.requestUpdate(); } init(container: HTMLElement, items: HTMLElement[]) { @@ -85,6 +136,8 @@ export class OverflowController implements ReactiveController { } hostConnected(): void { + this.#mo.observe(this.host, { attributes: false, childList: true, subtree: true }); + this.#ro.observe(this.host); this.onScroll(); this.#setOverflowState(); } diff --git a/core/pfe-core/controllers/property-observer-controller.ts b/core/pfe-core/controllers/property-observer-controller.ts index cc786f333d..0e5d0b6698 100644 --- a/core/pfe-core/controllers/property-observer-controller.ts +++ b/core/pfe-core/controllers/property-observer-controller.ts @@ -12,11 +12,11 @@ export type ChangeCallbackName = `_${string}Changed`; export type PropertyObserverHost = T & Record> & { [observedController]: PropertyObserverController; -} +}; /** This controller holds a cache of observed property values which were set before the element updated */ export class PropertyObserverController implements ReactiveController { - private static hosts: WeakMap = new WeakMap(); + private static hosts = new WeakMap(); private values = new Map(); diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index e4a2ca53f1..133cce7bab 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -1,32 +1,56 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { RequireProps } from '../core.js'; const isFocusableElement = (el: Element): el is HTMLElement => - !!el && - !el.hasAttribute('disabled') && - !el.ariaHidden && - !el.hasAttribute('hidden'); + !!el + && !el.ariaHidden + && !el.hasAttribute('hidden'); + +export interface RovingTabindexControllerOptions { + /** @deprecated use getHTMLElement */ + getElement?: () => Element | null; + getHTMLElement?: () => HTMLElement | null; + getItems?: () => Item[]; + getItemContainer?: () => HTMLElement; +} /** * Implements roving tabindex, as described in WAI-ARIA practices, [Managing Focus Within - * Components Using a Roving - * tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) + * Components Using a Roving tabindex][rti] + * + * [rti]: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex */ export class RovingTabindexController< - ItemType extends HTMLElement = HTMLElement, + Item extends HTMLElement = HTMLElement > implements ReactiveController { + private static hosts = new WeakMap(); + + static of( + host: ReactiveControllerHost, + options: RovingTabindexControllerOptions & { getItems(): Item[] }, + ) { + return new RovingTabindexController(host, options); + } + + /** @internal */ + static elements = new WeakMap(); + /** active focusable element */ - #activeItem?: ItemType; + #activeItem?: Item; /** closest ancestor containing items */ - #itemsContainer?: HTMLElement; + #itemsContainer?: Element; /** array of all focusable elements */ - #items: ItemType[] = []; + #items: Item[] = []; + + /** flags whether the host's element has gained focus at least once */ + #gainedInitialFocus = false; /** * finds focusable items from a group of items */ - get #focusableItems(): ItemType[] { + get #focusableItems(): Item[] { return this.#items.filter(isFocusableElement); } @@ -34,7 +58,8 @@ export class RovingTabindexController< * index of active item in array of focusable items */ get #activeIndex(): number { - return !!this.#focusableItems && !!this.activeItem ? this.#focusableItems.indexOf(this.activeItem) : -1; + return !!this.#focusableItems + && !!this.activeItem ? this.#focusableItems.indexOf(this.activeItem) : -1; } /** @@ -47,111 +72,170 @@ export class RovingTabindexController< /** * active item of array of items */ - get activeItem(): ItemType | undefined { + get activeItem(): Item | undefined { return this.#activeItem; } + /** + * all items from array + */ + get items() { + return this.#items; + } + + /** + * all focusable items from array + */ + get focusableItems() { + return this.#focusableItems; + } + /** * first item in array of focusable items */ - get firstItem(): ItemType | undefined { + get firstItem(): Item | undefined { return this.#focusableItems[0]; } /** * last item in array of focusable items */ - get lastItem(): ItemType | undefined { + get lastItem(): Item | undefined { return this.#focusableItems.at(-1); } /** * next item after active item in array of focusable items */ - get nextItem(): ItemType | undefined { + get nextItem(): Item | undefined { return ( - this.#activeIndex >= this.#focusableItems.length - 1 ? this.firstItem - : this.#focusableItems[this.#activeIndex + 1] + this.#activeIndex >= this.#focusableItems.length - 1 ? this.firstItem + : this.#focusableItems[this.#activeIndex + 1] ); } /** * previous item after active item in array of focusable items */ - get prevItem(): ItemType | undefined { + get prevItem(): Item | undefined { return ( - this.#activeIndex > 0 ? this.#focusableItems[this.#activeIndex - 1] - : this.lastItem + this.#activeIndex > 0 ? this.#focusableItems[this.#activeIndex - 1] + : this.lastItem ); } - constructor(public host: ReactiveControllerHost & HTMLElement) { + #options: RequireProps, 'getHTMLElement'>; + + constructor( + public host: ReactiveControllerHost, + options?: RovingTabindexControllerOptions, + ) { + this.#options = { + getHTMLElement: options?.getHTMLElement + ?? (options?.getElement as (() => HTMLElement | null)) + ?? (() => host instanceof HTMLElement ? host : null), + getItems: options?.getItems, + getItemContainer: options?.getItemContainer, + }; + const instance = RovingTabindexController.hosts.get(host); + if (instance) { + return instance as RovingTabindexController; + } + RovingTabindexController.hosts.set(host, this); this.host.addController(this); + this.updateItems(); + } + + hostUpdated() { + const oldContainer = this.#itemsContainer; + const newContainer = this.#options.getHTMLElement(); + if (oldContainer !== newContainer) { + oldContainer?.removeEventListener('keydown', this.#onKeydown); + RovingTabindexController.elements.delete(oldContainer!); + this.updateItems(); + } + if (newContainer) { + this.#initContainer(newContainer); + } + } + + /** + * removes event listeners from items container + */ + hostDisconnected() { + this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown); + this.#itemsContainer = undefined; + this.#gainedInitialFocus = false; + } + + #initContainer(container: Element) { + RovingTabindexController.elements.set(container, this); + this.#itemsContainer = container; + this.#itemsContainer.addEventListener('keydown', this.#onKeydown); + this.#itemsContainer.addEventListener('focusin', () => { + this.#gainedInitialFocus = true; + }, { once: true }); } /** * handles keyboard navigation */ - #onKeydown = (event: KeyboardEvent) => { - if (event.ctrlKey || - event.altKey || - event.metaKey || - !this.#focusableItems.length || - !event.composedPath().some(x => - this.#focusableItems.includes(x as ItemType))) { + #onKeydown = (event: Event) => { + if (!(event instanceof KeyboardEvent) + || event.ctrlKey + || event.altKey + || event.metaKey + || !this.#focusableItems.length + || !event.composedPath().some(x => + this.#focusableItems.includes(x as Item))) { return; } + + const orientation = this.#options.getHTMLElement()?.getAttribute('aria-orientation'); + const item = this.activeItem; let shouldPreventDefault = false; const horizontalOnly = - !item ? false - : item.tagName === 'SELECT' || - item.getAttribute('role') === 'spinbutton'; - - + !item ? false + : item.tagName === 'SELECT' + || item.getAttribute('role') === 'spinbutton' || orientation === 'horizontal'; + const verticalOnly = orientation === 'vertical'; switch (event.key) { case 'ArrowLeft': - this.focusOnItem(this.prevItem); + if (verticalOnly) { + return; + } + this.setActiveItem(this.prevItem); shouldPreventDefault = true; break; case 'ArrowRight': - this.focusOnItem(this.nextItem); + if (verticalOnly) { + return; + } + + this.setActiveItem(this.nextItem); shouldPreventDefault = true; break; case 'ArrowUp': if (horizontalOnly) { return; } - this.focusOnItem(this.prevItem); + this.setActiveItem(this.prevItem); shouldPreventDefault = true; break; case 'ArrowDown': if (horizontalOnly) { return; } - this.focusOnItem(this.nextItem); + this.setActiveItem(this.nextItem); shouldPreventDefault = true; break; case 'Home': - this.focusOnItem(this.firstItem); - shouldPreventDefault = true; - break; - case 'PageUp': - if (horizontalOnly) { - return; - } - this.focusOnItem(this.firstItem); + this.setActiveItem(this.firstItem); shouldPreventDefault = true; break; case 'End': - this.focusOnItem(this.lastItem); - shouldPreventDefault = true; - break; - case 'PageDown': - if (horizontalOnly) { - return; - } - this.focusOnItem(this.lastItem); + this.setActiveItem(this.lastItem); shouldPreventDefault = true; break; default: @@ -165,68 +249,50 @@ export class RovingTabindexController< }; /** - * sets tabindex of item based on whether or not it is active + * Sets the active item and focuses it */ - updateActiveItem(item?: ItemType): void { - if (item) { - if (!!this.#activeItem && item !== this.#activeItem) { - this.#activeItem.tabIndex = -1; - } - item.tabIndex = 0; - this.#activeItem = item; + setActiveItem(item?: Item): void { + this.#activeItem = item; + for (const item of this.#focusableItems) { + item.tabIndex = this.#activeItem === item ? 0 : -1; } - } - - /** - * focuses on an item and sets it as active - */ - focusOnItem(item?: ItemType): void { - this.updateActiveItem(item || this.firstItem); - this.#activeItem?.focus(); this.host.requestUpdate(); + if (this.#gainedInitialFocus) { + this.#activeItem?.focus(); + } } /** * Focuses next focusable item */ - updateItems(items: ItemType[]) { - const sequence = [...items.slice(this.#itemIndex), ...items.slice(0, this.#itemIndex)]; + updateItems(items: Item[] = this.#options.getItems?.() ?? []) { + this.#items = items; + const sequence = [ + ...this.#items.slice(this.#itemIndex - 1), + ...this.#items.slice(0, this.#itemIndex - 1), + ]; const first = sequence.find(item => this.#focusableItems.includes(item)); - this.focusOnItem(first || this.firstItem); + const [focusableItem] = this.#focusableItems; + const activeItem = focusableItem ?? first ?? this.firstItem; + this.setActiveItem(activeItem); } - /** - * from array of HTML items, and sets active items - */ - initItems(items: ItemType[], itemsContainer: HTMLElement = this.host) { - this.#items = items ?? []; - const focusableItems = this.#focusableItems; - const [focusableItem] = focusableItems; - this.#activeItem = focusableItem; - for (const item of focusableItems) { - item.tabIndex = this.#activeItem === item ? 0 : -1; - } - /** - * removes listener on previous contained and applies it to new container - */ - if (!this.#itemsContainer || itemsContainer !== this.#itemsContainer) { - this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown); - this.#itemsContainer = itemsContainer; - this.hostConnected(); - } + /** @deprecated use setActiveItem */ + focusOnItem(item?: Item): void { + this.setActiveItem(item); } /** - * adds event listeners to items container - */ - hostConnected() { - this.#itemsContainer?.addEventListener('keydown', this.#onKeydown); - } - - /** - * removes event listeners from items container + * from array of HTML items, and sets active items + * @deprecated: use getItems and getItemContainer option functions */ - hostDisconnected() { - this.#itemsContainer?.removeEventListener('keydown', this.#onKeydown); + initItems(items: Item[], itemsContainer?: Element) { + const element = itemsContainer + ?? this.#options?.getItemContainer?.() + ?? this.#options.getHTMLElement(); + if (element) { + this.#initContainer(element); + } + this.updateItems(items); } } diff --git a/core/pfe-core/controllers/scroll-spy-controller.ts b/core/pfe-core/controllers/scroll-spy-controller.ts index f95a1dd3bf..60c223d1ca 100644 --- a/core/pfe-core/controllers/scroll-spy-controller.ts +++ b/core/pfe-core/controllers/scroll-spy-controller.ts @@ -49,7 +49,7 @@ export class ScrollSpyController implements ReactiveController { get #linkChildren(): Element[] { return Array.from(this.host.querySelectorAll(this.#tagNames.join(','))) - .filter(this.#getHash); + .filter(this.#getHash); } get root() { @@ -106,11 +106,11 @@ export class ScrollSpyController implements ReactiveController { const { rootMargin, threshold, root } = this; this.#io = new IntersectionObserver(r => this.#onIo(r), { root, rootMargin, threshold }); this.#linkChildren - .map(x => this.#getHash(x)) - .filter((x): x is string => !!x) - .map(x => rootNode.getElementById(x.replace('#', ''))) - .filter((x): x is HTMLElement => !!x) - .forEach(target => this.#io?.observe(target)); + .map(x => this.#getHash(x)) + .filter((x): x is string => !!x) + .map(x => rootNode.getElementById(x.replace('#', ''))) + .filter((x): x is HTMLElement => !!x) + .forEach(target => this.#io?.observe(target)); } } diff --git a/core/pfe-core/controllers/slot-controller.ts b/core/pfe-core/controllers/slot-controller.ts index d5ef657784..c271797715 100644 --- a/core/pfe-core/controllers/slot-controller.ts +++ b/core/pfe-core/controllers/slot-controller.ts @@ -32,7 +32,9 @@ export interface SlotsConfig { deprecations?: Record; } -function isObjectConfigSpread(config: ([SlotsConfig] | (string | null)[])): config is [SlotsConfig] { +function isObjectConfigSpread( + config: ([SlotsConfig] | (string | null)[]), +): config is [SlotsConfig] { return config.length === 1 && typeof config[0] === 'object' && config[0] !== null; } @@ -41,15 +43,17 @@ function isObjectConfigSpread(config: ([SlotsConfig] | (string | null)[])): conf * for the default slot, look for direct children not assigned to a slot */ const isSlot = - (n: string | typeof SlotController.anonymous) => + (n: string | typeof SlotController.default) => (child: Element): child is T => - n === SlotController.anonymous ? !child.hasAttribute('slot') + n === SlotController.default ? !child.hasAttribute('slot') : child.getAttribute('slot') === n; export class SlotController implements ReactiveController { - public static anonymous = Symbol('anonymous slot'); + public static default = Symbol('default slot'); + /** @deprecated use `default` */ + public static anonymous = this.default; - #nodes = new Map(); + #nodes = new Map(); #logger: Logger; @@ -105,36 +109,17 @@ export class SlotController implements ReactiveController { this.#mo.disconnect(); } - /** - * Returns a boolean statement of whether or not any of those slots exists in the light DOM. - * - * @param {String|Array} name The slot name. - * @example this.hasSlotted("header"); - */ - hasSlotted(...names: string[]): boolean { - if (!names.length) { - this.#logger.warn(`Please provide at least one slot name for which to search.`); - return false; - } else { - return names.some(x => - this.#nodes.get(x)?.hasContent ?? false); - } - } - /** * Given a slot name or slot names, returns elements assigned to the requested slots as an array. * If no value is provided, it returns all children not assigned to a slot (without a slot attribute). - * * @example Get header-slotted elements * ```js * this.getSlotted('header') * ``` - * * @example Get header- and footer-slotted elements * ```js * this.getSlotted('header', 'footer') * ``` - * * @example Get default-slotted elements * ```js * this.getSlotted(); @@ -142,13 +127,38 @@ export class SlotController implements ReactiveController { */ getSlotted(...slotNames: string[]): T[] { if (!slotNames.length) { - return (this.#nodes.get(SlotController.anonymous)?.elements ?? []) as T[]; + return (this.#nodes.get(SlotController.default)?.elements ?? []) as T[]; } else { return slotNames.flatMap(slotName => this.#nodes.get(slotName)?.elements ?? []) as T[]; } } + /** + * Returns a boolean statement of whether or not any of those slots exists in the light DOM. + * @param names The slot names to check. + * @example this.hasSlotted('header'); + */ + hasSlotted(...names: (string | null | undefined)[]): boolean { + const { anonymous } = SlotController; + const slotNames = Array.from(names, x => x == null ? anonymous : x); + if (!slotNames.length) { + slotNames.push(anonymous); + } + return slotNames.some(x => this.#nodes.get(x)?.hasContent ?? false); + } + + /** + * Whether or not all the requested slots are empty. + * @param slots The slot name. If no value is provided, it returns the default slot. + * @example this.isEmpty('header', 'footer'); + * @example this.isEmpty(); + * @returns + */ + isEmpty(...names: (string | null | undefined)[]): boolean { + return !this.hasSlotted(...names); + } + #onSlotChange = (event: Event & { target: HTMLSlotElement }) => { const slotName = event.target.name; this.#initSlot(slotName); @@ -168,14 +178,17 @@ export class SlotController implements ReactiveController { this.host.requestUpdate(); }; - #getChildrenForSlot(name: string | typeof SlotController.anonymous): T[] { + #getChildrenForSlot( + name: string | typeof SlotController.default, + ): T[] { const children = Array.from(this.host.children) as T[]; return children.filter(isSlot(name)); } #initSlot = (slotName: string | null) => { - const name = slotName || SlotController.anonymous; - const elements = this.#nodes.get(name)?.slot?.assignedElements?.() ?? this.#getChildrenForSlot(name); + const name = slotName || SlotController.default; + const elements = this.#nodes.get(name)?.slot?.assignedElements?.() + ?? this.#getChildrenForSlot(name); const selector = slotName ? `slot[name="${slotName}"]` : 'slot:not([name])'; const slot = this.host.shadowRoot?.querySelector?.(selector) ?? null; const hasContent = !!elements.length; diff --git a/core/pfe-core/controllers/style-controller.ts b/core/pfe-core/controllers/style-controller.ts index a030784629..ef3ca52a46 100644 --- a/core/pfe-core/controllers/style-controller.ts +++ b/core/pfe-core/controllers/style-controller.ts @@ -1,8 +1,14 @@ -import type { ReactiveController, ReactiveElement, CSSResultGroup, CSSResultOrNative, CSSResult } from 'lit'; +import type { + ReactiveController, + ReactiveElement, + CSSResultGroup, + CSSResultOrNative, + CSSResult, +} from 'lit'; import { getCompatibleStyle, supportsAdoptingStyleSheets } from 'lit'; declare global { - interface ShadowRoot { + interface ShadowRoot { adoptedStyleSheets: CSSStyleSheet[]; } } @@ -28,7 +34,9 @@ export class StyleController implements ReactiveController { return; } - const styles = [this.styles].flatMap(x => getCompatibleStyle(x as CSSResultOrNative)).filter(x => !!x); + const styles = [this.styles] + .flatMap(x => getCompatibleStyle(x as CSSResultOrNative)) + .filter(x => !!x); if (supportsAdoptingStyleSheets) { this.host.renderRoot.adoptedStyleSheets = [ diff --git a/core/pfe-core/controllers/tabs-aria-controller.ts b/core/pfe-core/controllers/tabs-aria-controller.ts new file mode 100644 index 0000000000..93bb25659e --- /dev/null +++ b/core/pfe-core/controllers/tabs-aria-controller.ts @@ -0,0 +1,125 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; + +export interface TabsAriaControllerOptions { + /** Add an `isTab` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ + isTab: (node: unknown) => node is Tab; + isActiveTab: (tab: Tab) => boolean; + /** Add an `isPanel` predicate to ensure this tabs instance' state does not leak into parent tabs' state */ + isPanel: (node: unknown) => node is Panel; + getHTMLElement?: () => HTMLElement; +} + +export class TabsAriaController< + Tab extends HTMLElement = HTMLElement, + Panel extends HTMLElement = HTMLElement +> implements ReactiveController { + #logger: Logger; + + #host: ReactiveControllerHost; + + #element: HTMLElement; + + #tabPanelMap = new Map(); + + #options: TabsAriaControllerOptions; + + #mo = new MutationObserver(this.#onSlotchange.bind(this)); + + get tabs() { + return [...this.#tabPanelMap.keys()] as Tab[]; + } + + get activeTab(): Tab | undefined { + return this.tabs.find(x => this.#options.isActiveTab(x)); + } + + /** + * @example Usage in PfTab + * ```ts + * new TabsController(this, { + * isTab: (x): x is PfTab => x instanceof PfTab, + * isPanel: (x): x is PfTabPanel => x instanceof PfTabPanel + * }); + * ``` + */ + constructor( + host: ReactiveControllerHost, + options: TabsAriaControllerOptions, + ) { + this.#options = options; + this.#logger = new Logger(host); + if (host instanceof HTMLElement) { + this.#element = host; + } else { + const element = options.getHTMLElement?.(); + if (!element) { + throw new Error( + 'TabsController must be instantiated with an HTMLElement or a `getHTMLElement()` option', + ); + } + this.#element = element; + } + (this.#host = host).addController(this); + this.#element.addEventListener('slotchange', this.#onSlotchange); + if (this.#element.isConnected) { + this.hostConnected(); + } + } + + hostConnected() { + this.#mo.observe(this.#element, { attributes: false, childList: true, subtree: false }); + this.#onSlotchange(); + } + + hostUpdated() { + for (const [tab, panel] of this.#tabPanelMap) { + panel.setAttribute('aria-labelledby', tab.id); + tab.setAttribute('aria-controls', panel.id); + } + } + + hostDisconnected(): void { + this.#mo.disconnect(); + } + + /** + * zip the tabs and panels together into #tabPanelMap + */ + #onSlotchange() { + this.#tabPanelMap.clear(); + const tabs = []; + const panels = []; + for (const child of this.#element.children) { + if (this.#options.isTab(child)) { + tabs.push(child); + child.role ??= 'tab'; + } else if (this.#options.isPanel(child)) { + panels.push(child); + child.role ??= 'tabpanel'; + } + } + if (tabs.length > panels.length) { + this.#logger.warn('Too many tabs!'); + } else if (panels.length > tabs.length) { + this.#logger.warn('Too many panels!'); + } + while (tabs.length) { + this.#tabPanelMap.set(tabs.shift()!, panels.shift()!); + } + this.#host.requestUpdate(); + } + + panelFor(tab: Tab): Panel | undefined { + return this.#tabPanelMap.get(tab); + } + + tabFor(panel: Panel): Tab | undefined { + for (const [tab, panelToCheck] of this.#tabPanelMap) { + if (panel === panelToCheck) { + return tab; + } + } + } +} diff --git a/core/pfe-core/controllers/timestamp-controller.ts b/core/pfe-core/controllers/timestamp-controller.ts index 4a8158864d..51727d7d82 100644 --- a/core/pfe-core/controllers/timestamp-controller.ts +++ b/core/pfe-core/controllers/timestamp-controller.ts @@ -55,7 +55,8 @@ export class TimestampController implements ReactiveController { if (this.#options.relative) { return this.#getTimeRelative(); } else { - let { displaySuffix, locale } = this.#options; + let { displaySuffix } = this.#options; + const { locale } = this.#options; if (this.#options.utc) { displaySuffix ||= 'UTC'; } @@ -79,7 +80,7 @@ export class TimestampController implements ReactiveController { } } - hostConnected?(): void + hostConnected?(): void; /** * Based off of Github Relative Time @@ -88,7 +89,11 @@ export class TimestampController implements ReactiveController { #getTimeRelative() { const date = this.#date; const { locale } = this.#options; - const rtf = new Intl.RelativeTimeFormat(locale as string, { localeMatcher: 'best fit', numeric: 'auto', style: 'long' }); + const rtf = new Intl.RelativeTimeFormat(locale as string, { + localeMatcher: 'best fit', + numeric: 'auto', + style: 'long', + }); const ms: number = date.getTime() - Date.now(); const tense = ms > 0 ? 1 : -1; let qty = 0; diff --git a/core/pfe-core/core.ts b/core/pfe-core/core.ts index dd488d2874..55519b5c93 100644 --- a/core/pfe-core/core.ts +++ b/core/pfe-core/core.ts @@ -10,6 +10,10 @@ export interface PfeConfig { autoReveal?: boolean; } +export type RequireProps = T & { + [P in Ps]-?: T[P]; +}; + const noPref = Symbol(); /** Retrieve an HTML metadata item */ @@ -29,7 +33,9 @@ export function trackPerformance(preference: boolean | typeof noPref = noPref) { return window.PfeConfig.trackPerformance; } -function makeConverter(f: (x: string, type?: unknown) => T): ComplexAttributeConverter { +function makeConverter( + f: (x: string, type?: unknown) => T, +): ComplexAttributeConverter { return { fromAttribute(value: string) { if (typeof value !== 'string') { @@ -67,7 +73,7 @@ export class ComposedEvent extends Event { super(type, { bubbles: true, composed: true, - ...init + ...init, }); } } @@ -85,7 +91,8 @@ const bodyNoAutoReveal = document.body.hasAttribute('no-auto-reveal'); /** Global patternfly elements config */ window.PfeConfig = Object.assign(window.PfeConfig ?? {}, { - trackPerformance: window.PfeConfig?.trackPerformance ?? getMeta('pf-track-performance') === 'true', + trackPerformance: window.PfeConfig?.trackPerformance + ?? getMeta('pf-track-performance') === 'true', // if the body tag has `no-auto-reveal` attribute, reveal immediately // if `` exists, and it's `content` is 'true', // then auto-reveal the body diff --git a/core/pfe-core/decorators/cascades.ts b/core/pfe-core/decorators/cascades.ts index 0856471aec..f800aa93bf 100644 --- a/core/pfe-core/decorators/cascades.ts +++ b/core/pfe-core/decorators/cascades.ts @@ -4,6 +4,7 @@ import { CascadeController } from '../controllers/cascade-controller.js'; /** * Cascades the decorated attribute to children + * @deprecated: use context, especially via `@patternfly/pfe-core/functions/context.js`; */ export function cascades(...items: string[]): PropertyDecorator { return function(proto: T, key: string & keyof T) { diff --git a/core/pfe-core/decorators/deprecation.ts b/core/pfe-core/decorators/deprecation.ts index 9a52652340..20c5ff7c27 100644 --- a/core/pfe-core/decorators/deprecation.ts +++ b/core/pfe-core/decorators/deprecation.ts @@ -5,7 +5,7 @@ import { Logger } from '../controllers/logger.js'; export type DeprecationDeclaration = PropertyDeclaration & { alias: string & K; attribute: string; -} +}; /** * Aliases the decorated field to an existing property, and logs a warning if it is used diff --git a/core/pfe-core/decorators/observed.ts b/core/pfe-core/decorators/observed.ts index edb5468b2b..48806b40ce 100644 --- a/core/pfe-core/decorators/observed.ts +++ b/core/pfe-core/decorators/observed.ts @@ -17,42 +17,39 @@ type TypedFieldDecorator = (proto: T, key: string | keyof T) => void ; * Works on any class field. When using on lit observed properties, * Make sure `@observed` is to the left (i.e. called after) the `@property` * or `@state` decorator. - * * @example observing a lit property * ```ts * @observed @property() foo = 'bar'; * * protected _fooChanged(oldValue?: string, newValue?: string) {} * ``` - * * @example using a custom callback * ```ts * @observed('_myCallback') size = 'lg'; * * _myCallback(_, size) {...} * ``` - * * @example using an arrow function * ```ts * @observed((oldVal, newVal) => console.log(`Size changed from ${oldVal} to ${newVal}`)) * ``` */ -export function observed(methodName: string): TypedFieldDecorator -export function observed(cb: ChangeCallback): TypedFieldDecorator -export function observed(proto: T, key: string): void +export function observed(methodName: string): TypedFieldDecorator; +export function observed(cb: ChangeCallback): TypedFieldDecorator; +export function observed(proto: T, key: string): void; export function observed(...as: any[]): void | TypedFieldDecorator { /** @observed('_myCustomChangeCallback') */ if (as.length === 1) { const [methodNameOrCallback] = as; return function(proto, key) { (proto.constructor as typeof ReactiveElement) - .addInitializer(x => new PropertyObserverController(x)); + .addInitializer(x => new PropertyObserverController(x)); observeProperty(proto, key as string & keyof T, methodNameOrCallback); }; } else { const [proto, key] = as; (proto.constructor as typeof ReactiveElement) - .addInitializer(x => new PropertyObserverController(x)); + .addInitializer(x => new PropertyObserverController(x)); observeProperty(proto, key); } } diff --git a/core/pfe-core/functions/containsDeep.ts b/core/pfe-core/functions/containsDeep.ts new file mode 100644 index 0000000000..f339507fa7 --- /dev/null +++ b/core/pfe-core/functions/containsDeep.ts @@ -0,0 +1,19 @@ +/** + * Whether or not the container contains the node, + * and if not, whether the node is contained by any element + * slotted in to the container + */ +export function containsDeep(container: Element, node: Node) { + if (container.contains(node)) { + return true; + } else { + for (const slot of container.querySelectorAll('slot') ?? []) { + for (const el of slot.assignedElements()) { + if (el.contains(node)) { + return true; + } + } + } + return false; + } +} diff --git a/core/pfe-core/functions/context.ts b/core/pfe-core/functions/context.ts new file mode 100644 index 0000000000..ec97e4bcfb --- /dev/null +++ b/core/pfe-core/functions/context.ts @@ -0,0 +1,19 @@ +import { ContextRoot, createContext } from '@lit/context'; + +let root: ContextRoot; + +function makeContextRoot() { + root = new ContextRoot(); + root.attach(document.body); + return root; +} + +/** + * In order to prevent late-upgrading-context-consumers from 'missing' + * their rightful context providers, we must set up a `ContextRoot` on the body. + * Always use this function when creating contexts that are shared with child elements. + */ +export function createContextWithRoot(...args: Parameters) { + root ??= makeContextRoot(); + return createContext(...args); +} diff --git a/core/pfe-core/functions/deprecatedCustomEvent.ts b/core/pfe-core/functions/deprecatedCustomEvent.ts deleted file mode 100644 index 61a0d32e05..0000000000 --- a/core/pfe-core/functions/deprecatedCustomEvent.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Construct a CustomEvent with the given name and detail. - * The event bubbles and is composed. - */ -export function deprecatedCustomEvent(name: string, detail?: T): CustomEvent { - return new CustomEvent(name, { - bubbles: true, - composed: true, - detail, - }); -} diff --git a/core/pfe-core/functions/isElementInView.ts b/core/pfe-core/functions/isElementInView.ts index c4cc9cc1a7..c18e277fe9 100644 --- a/core/pfe-core/functions/isElementInView.ts +++ b/core/pfe-core/functions/isElementInView.ts @@ -1,12 +1,10 @@ /** * This function returns whether or not an element is within the viewable area of a container. If partial is true, * then this function will return true even if only part of the element is in view. - * - * @param {HTMLElement} container The container to check if the element is in view of. - * @param {HTMLElement} element The element to check if it is view - * @param {boolean} partial true if partial view is allowed - * @param {boolean} strict true if strict mode is set, never consider the container width and element width - * + * @param container The container to check if the element is in view of. + * @param element The element to check if it is view + * @param partial true if partial view is allowed + * @param strict true if strict mode is set, never consider the container width and element width * @returns True if the component is in View. */ export function isElementInView( @@ -27,12 +25,12 @@ export function isElementInView( // Check if in view const isTotallyInView = - elementBoundsLeft >= containerBoundsLeft && - elementBoundsRight <= containerBoundsRight; + elementBoundsLeft >= containerBoundsLeft + && elementBoundsRight <= containerBoundsRight; const isPartiallyInView = - (partial || (!strict && containerBounds.width < elementBounds.width)) && - ((elementBoundsLeft < containerBoundsLeft && elementBoundsRight > containerBoundsLeft) || - (elementBoundsRight > containerBoundsRight && elementBoundsLeft < containerBoundsRight)); + (partial || (!strict && containerBounds.width < elementBounds.width)) + && ((elementBoundsLeft < containerBoundsLeft && elementBoundsRight > containerBoundsLeft) + || (elementBoundsRight > containerBoundsRight && elementBoundsLeft < containerBoundsRight)); // Return outcome return isTotallyInView || isPartiallyInView; diff --git a/core/pfe-core/package.json b/core/pfe-core/package.json index 3180309240..504fddee9c 100644 --- a/core/pfe-core/package.json +++ b/core/pfe-core/package.json @@ -1,6 +1,6 @@ { "name": "@patternfly/pfe-core", - "version": "2.4.1", + "version": "3.0.0", "license": "MIT", "description": "PatternFly Elements Core Library", "customElements": "custom-elements.json", @@ -29,6 +29,7 @@ "./controllers/slot-controller.js": "./controllers/slot-controller.js", "./controllers/style-controller.js": "./controllers/style-controller.js", "./controllers/timestamp-controller.js": "./controllers/timestamp-controller.js", + "./controllers/tabs-controller.js": "./controllers/tabs-controller.js", "./decorators/bound.js": "./decorators/bound.js", "./decorators/cascades.js": "./decorators/cascades.js", "./decorators/deprecation.js": "./decorators/deprecation.js", @@ -36,8 +37,9 @@ "./decorators/observed.js": "./decorators/observed.js", "./decorators/time.js": "./decorators/time.js", "./decorators/trace.js": "./decorators/trace.js", + "./functions/context.js": "./functions/context.js", + "./functions/containsDeep.js": "./functions/containsDeep.js", "./functions/debounce.js": "./functions/debounce.js", - "./functions/deprecatedCustomEvent.js": "./functions/deprecatedCustomEvent.js", "./functions/random.js": "./functions/random.js", "./functions/isElementInView.js": "./functions/isElementInView.js" }, @@ -51,8 +53,9 @@ "test": "wtr --files './test/*.spec.ts' --config ../../web-test-runner.config.js" }, "dependencies": { - "@floating-ui/dom": "^1.2.6", - "lit": "^2.7.2" + "@floating-ui/dom": "^1.6.3", + "@lit/context": "^1.1.0", + "lit": "^3.1.2" }, "repository": { "type": "git", diff --git a/declaration.d.ts b/declaration.d.ts index 7cecb95346..7df17c6565 100644 --- a/declaration.d.ts +++ b/declaration.d.ts @@ -1,7 +1,4 @@ declare module '*.css' { - import type { CSSResult } from 'lit'; - - // import style from './some-styles.css'; - const style: CSSResult; + const style: CSSStyleSheet export default style; } diff --git a/docs/_data/importMap.cjs b/docs/_data/importMap.cjs index 4334cadce2..6214503d9f 100644 --- a/docs/_data/importMap.cjs +++ b/docs/_data/importMap.cjs @@ -1,10 +1,13 @@ -const fs = require('fs'); -const path = require('path'); -const { promisify } = require('node:util'); -const Glob = require('glob'); -const glob = promisify(Glob); +const fs = require('node:fs'); +const path = require('node:path'); +const { glob } = require('glob'); -const packageLock = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package-lock.json'))); +const packageLock = JSON.parse(fs.readFileSync(path.join( + __dirname, + '..', + '..', + 'package-lock.json', +))); function readPackageVersion(module) { return packageLock.packages[`node_modules/${module}`].version; @@ -46,13 +49,18 @@ const LIT_DEPS = [ './decorators.js', './directive.js', './directive-helpers.js', - './experimental-hydrate-support.js', - './experimental-hydrate.js', './html.js', './polyfill-support.js', './static-html.js', - ] - } + ], + }, + { + target: `@lit-labs/ssr-client`, + subpaths: [ + '.', + './lit-element-hydrate-support.js', + ], + }, ]; const PWA_DEPS = [ @@ -60,17 +68,17 @@ const PWA_DEPS = [ target: `pwa-helpers@${PWA_HELPER_VERSION}`, subpaths: [ '.', - './router.js' - ] - } + './router.js', + ], + }, ]; module.exports = async function() { const { Generator } = await import('@jspm/generator'); const generator = new Generator({ - defaultProvider: 'jspm', - env: ['production', 'browser', 'module'] + defaultProvider: 'jspm.io', + env: ['production', 'browser', 'module'], }); await generator.install([ @@ -83,7 +91,7 @@ module.exports = async function() { 'element-internals-polyfill', `fuse.js@${FUSE_VERSION}`, ...LIT_DEPS, - ...PWA_DEPS + ...PWA_DEPS, ]); const map = generator.getMap(); @@ -91,20 +99,43 @@ module.exports = async function() { map.imports['@patternfly/elements'] = '/pfe.min.js'; // add imports for imports under pfe-core - const pfeCoreImports = (await glob('./{functions,controllers,decorators}/*.ts', { cwd: path.join(__dirname, '../../core/pfe-core') })) - .filter(x => !x.endsWith('.d.ts')) - .map(x => x.replace('.ts', '.js')); + const pfeCoreImports = (await glob('./{functions,controllers,decorators}/*.ts', { + cwd: path.join(__dirname, '../../core/pfe-core'), + })) + .filter(x => !x.endsWith('.d.ts')) + .map(x => x.replace('.ts', '.js')); for (const file of pfeCoreImports) { map.imports[path.join('@patternfly/pfe-core', file)] = '/pfe.min.js'; } + map.imports['@patternfly/pfe-core/decorators.js'] = '/pfe.min.js'; map.imports['@patternfly/pfe-core'] = '/pfe.min.js'; - for (const tagName of fs.readdirSync(path.join(__dirname, '..', '..', 'elements'))) { - map.imports[`@patternfly/elements/${tagName}/${tagName}.js`] = `/pfe.min.js`; + const elementsPath = path.join(__dirname, '..', '..', 'elements'); + for (const tagName of fs.readdirSync(elementsPath)) { + const elementPath = path.join(elementsPath, tagName); + if (fs.statSync(elementPath).isDirectory()) { + for (const fileName of fs.readdirSync(elementPath)) { + if (fileName.endsWith('.ts') && !fileName.endsWith('.d.ts')) { + map.imports[`@patternfly/elements/${tagName}/${fileName.replace('.ts', '')}.js`] = `/pfe.min.js`; + } + } + } } + map.imports['@patternfly/pfe-tools/environment.js'] = '/tools/environment.js'; + // add imports for all icon files in /node_modules/@patternfly/icons/{far, fas, fab, patternfly}/ + const iconsImports = (await glob('./{far,fas,fab,patternfly}/*.js', { + cwd: path.join(__dirname, '../../node_modules/@patternfly/icons'), + })) + .filter(x => !x.endsWith('.d.ts')) + .map(x => x); + + for (const icon of iconsImports) { + map.imports[`@patternfly/icons/${icon}`] = `/assets/@patternfly/icons/${icon}`; + } + return map; }; diff --git a/docs/_data/versions.json b/docs/_data/versions.json index ae8554ff2f..04942189ea 100644 --- a/docs/_data/versions.json +++ b/docs/_data/versions.json @@ -1,9 +1,15 @@ [ + { + "version": "v3.0.0", + "slug": "v3", + "label": "v3", + "current": true + }, { "version": "v2.0.0", "slug": "v2", "label": "v2", - "current": true + "current": false }, { "version": "v1.12.3", diff --git a/docs/_includes/_nav.njk b/docs/_includes/_nav.njk index d177d3ca43..f1851d0dce 100644 --- a/docs/_includes/_nav.njk +++ b/docs/_includes/_nav.njk @@ -6,50 +6,33 @@
- - PatternFly Elements - + + PatternFly Elements + - {# TODO: implement pf-dropdown - - {% for version in versions %} - {%- if version.current -%} - {%- set prefix = '' -%} - {%- else -%} - {%- set prefix = '/' + version.slug -%} - {%- endif %} - - {{ version.label }} - - {% endfor %} + + Versions + + {% for version in versions %} + {%- if version.current -%} + {%- set prefix = '' -%} + {%- else -%} + {%- set prefix = '/' + version.slug -%} + {%- endif %} + + {{ version.label }} + + {% endfor %} + - #} - - @@ -58,7 +41,7 @@ data-controls="mobile-menu-menu-container"> Toggle menu visibility - + - - -
-

Filled Color

- Blue - Green - Orange - Red Hat - Purple - Cyan - Gold - Grey - - - -
- -
-

Outline Color

- Blue - Green - Orange - Red - Purple - Cyan - Gold - Grey -
- Example: - - - -
-
- -
-

With Icon

-
- Default - Blue label - Green label - Orange label - Red label - Purple label - Cyan label - Gold label -
-
- Default - Blue label - Green label - Orange label - Red label - Purple label - Cyan label - Gold label -
- -
- Example: Default - - - -
- -
- Slotted Icon: - - Red label - - - - - - -
- -
- Slotted <pf-icon>: - Default - - - - - -
- -
- Icon attribute empty: - Default - - - -
-
-

Compact

- Default - Blue label - Green label - Orange label - Red label - Purple label - Cyan label - Gold label - Grey label - -
- Example: Default - - - -
-
+ + diff --git a/elements/pf-label/demo/pf-label.js b/elements/pf-label/demo/pf-label.js deleted file mode 100644 index 40f56aa472..0000000000 --- a/elements/pf-label/demo/pf-label.js +++ /dev/null @@ -1,10 +0,0 @@ -import '/docs/zero-md.js'; - -import '@patternfly/elements/pf-label/pf-label.js'; - -const container = document.querySelector('[data-demo]'); - -container.addEventListener('close', function(event) { - event.target.remove(); -}); - diff --git a/elements/pf-label/docs/pf-label.md b/elements/pf-label/docs/pf-label.md index 4466257d6d..34c7ae8423 100644 --- a/elements/pf-label/docs/pf-label.md +++ b/elements/pf-label/docs/pf-label.md @@ -31,6 +31,17 @@ {% htmlexample %} + Red Warning diff --git a/elements/pf-label/pf-label.css b/elements/pf-label/pf-label.css index 8705c80cda..40f770bdb5 100644 --- a/elements/pf-label/pf-label.css +++ b/elements/pf-label/pf-label.css @@ -1,27 +1,61 @@ -#pf-container { - display: contents; +:host { + position: relative; + white-space: nowrap; + border: 0; } +pf-icon, ::slotted(pf-icon) { + color: currentColor; +} + +:host, #container { - --pf-global--icon--FontSize--sm: 14px; + display: inline-flex; + align-items: center; + vertical-align: middle; +} +#container { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-width: 0; padding-top: var(--pf-c-label--PaddingTop, var(--pf-global--spacer--xs, 0.25rem)); padding-left: var(--pf-c-label--PaddingLeft, var(--pf-global--spacer--sm, 0.5rem)); padding-bottom: var(--pf-c-label--PaddingBottom, var(--pf-global--spacer--xs, 0.25rem)); padding-right: var(--pf-c-label--PaddingRight, var(--pf-global--spacer--sm, 0.5rem)); - font-size: var(--pf-c-label--FontSize, 0.875em); + font-size: var(--pf-c-label--FontSize, var(--pf-global--FontSize--sm, 0.875rem)); color: var(--pf-c-label--Color, var(--pf-global--Color--100, #151515)); background-color: var(--pf-c-label--BackgroundColor, var(--pf-global--palette--black-150, #f5f5f5)); border-radius: var(--pf-c-label--BorderRadius, 30em); max-width: var(--pf-c-label__content--MaxWidth, 100%); color: var(--pf-c-label__content--Color, var(--pf-global--Color--100, #151515)); + + --pf-global--icon--FontSize--sm: 14px; } #container::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + content: ""; border-radius: var(--pf-c-label--BorderRadius, 30em); border: var(--pf-c-label__content--before--BorderWidth, 1px) solid var(--pf-c-label__content--before--BorderColor, var(--pf-global--palette--black-300, #d2d2d2)); } +[part=icon] { + display: none; + pointer-events: none; +} + +.hasIcon [part=icon] { + display: inline-flex; + width: 1em; +} + .compact { --pf-c-label--PaddingTop: var(--pf-c-label--m-compact--PaddingTop, 0); --pf-c-label--PaddingRight: var(--pf-c-label--m-compact--PaddingRight, var(--pf-global--spacer--sm, 0.5rem)); @@ -30,7 +64,6 @@ --pf-global--icon--FontSize--sm: 12px; } - .blue { --pf-c-label__content--Color: var(--pf-c-label--m-blue__content--Color, var(--pf-global--info-color--200, #002952)); --pf-c-label--BackgroundColor: var(--pf-c-label--m-blue--BackgroundColor, var(--pf-global--palette--blue-50, #e7f1fa)); diff --git a/elements/pf-label/pf-label.ts b/elements/pf-label/pf-label.ts index fa5309f3ee..068176765c 100644 --- a/elements/pf-label/pf-label.ts +++ b/elements/pf-label/pf-label.ts @@ -1,133 +1,114 @@ -import { html } from 'lit'; +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; import { classMap } from 'lit/directives/class-map.js'; -import { ComposedEvent } from '@patternfly/pfe-core'; - -import { BaseLabel } from './BaseLabel.js'; +import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; import '@patternfly/elements/pf-button/pf-button.js'; import styles from './pf-label.css'; -export type LabelVariant = ( - | 'filled' - | 'outline' -); - -export type LabelColor = ( - | 'blue' - | 'cyan' - | 'green' - | 'orange' - | 'purple' - | 'red' - | 'grey' - | 'gold' -) +export class LabelCloseEvent extends Event { + constructor() { + super('close', { bubbles: true, cancelable: true }); + } +} /** * The **label** component allows users to add specific element captions for user * clarity and convenience. - * * @summary Allows users to display meta data in a stylized form. - * - * @fires close - when a removable label's close button is clicked - * + * @fires {LabelCloseEvent} close - when a removable label's close button is clicked * @cssprop {} --pf-c-label--FontSize {@default `0.875em`} - * * @cssprop {} --pf-c-label--PaddingTop {@default `0.25rem`} * @cssprop {} --pf-c-label--PaddingRight {@default `0.5rem`} * @cssprop {} --pf-c-label--PaddingBottom {@default `0.25rem`} * @cssprop {} --pf-c-label--PaddingLeft {@default `0.5rem`} - * * @cssprop {} --pf-c-label--Color {@default `#151515`} * @cssprop {} --pf-c-label--BackgroundColor {@default `#f5f5f5`} - * * @cssprop {} --pf-c-label--BorderRadius {@default `30em`} - * * @cssprop {} --pf-c-label__content--MaxWidth {@default `100%`} * @cssprop {} --pf-c-label__content--Color {@default `#151515`} * @cssprop {} --pf-c-label__content--before--BorderWidth {@default `1px`} * @cssprop {} --pf-c-label__content--before--BorderColor {@default `#d2d2d2`} - * * @cssprop {} --pf-c-label--m-outline__content--Color {@default `#151515`} * @cssprop {} --pf-c-label--m-outline--BackgroundColor {@default `#ffffff`} - * * @cssprop {} --pf-c-label--m-blue__content--Color {@default `#002952`} * @cssprop {} --pf-c-label--m-blue--BackgroundColor {@default `#e7f1fa`} * @cssprop {} --pf-c-label--m-blue__content--before--BorderColor {@default `#bee1f4`} * @cssprop {} --pf-c-label--m-outline--m-blue__content--Color {@default `#06c`} - * * @cssprop {} --pf-c-label--m-cyan__content--Color {@default `#3b1f00`} * @cssprop {} --pf-c-label--m-cyan--BackgroundColor {@default `#f2f9f9`} * @cssprop {} --pf-c-label--m-cyan__content--before--BorderColor {@default `#a2d9d9`} * @cssprop {} --pf-c-label--m-outline--m-cyan__content--Color {@default `#005f60`} - * * @cssprop {} --pf-c-label--m-green__content--Color {@default `#1e4f18`} * @cssprop {} --pf-c-label--m-green--BackgroundColor {@default `#f3faf2`} * @cssprop {} --pf-c-label--m-green__content--before--BorderColor {@default `#bde5b8`} * @cssprop {} --pf-c-label--m-outline--m-green__content--Color {@default `#3e8635`} - * * @cssprop {} --pf-c-label--m-orange__content--Color {@default `#003737`} * @cssprop {} --pf-c-label--m-orange--BackgroundColor {@default `#fff6ec`} * @cssprop {} --pf-c-label--m-orange__content--before--BorderColor {@default `#f4b678`} * @cssprop {} --pf-c-label--m-outline--m-orange__content--Color {@default `#8f4700`} - * * @cssprop {} --pf-c-label--m-purple__content--Color {@default `#1f0066`} * @cssprop {} --pf-c-label--m-purple--BackgroundColor {@default `#f2f0fc`} * @cssprop {} --pf-c-label--m-purple__content--before--BorderColor {@default `#cbc1ff`} * @cssprop {} --pf-c-label--m-outline--m-purple__content--Color {@default `#6753ac`} - * * @cssprop {} --pf-c-label--m-red__content--Color {@default `#7d1007`} * @cssprop {} --pf-c-label--m-red--BackgroundColor {@default `#faeae8`} * @cssprop {} --pf-c-label--m-red__content--before--BorderColor {@default `#c9190b`} * @cssprop {} --pf-c-label--m-outline--m-red__content--Color {@default `#c9190b`} - * * @cssprop {} --pf-c-label--m-gold__content--Color {@default `#3d2c00`} * @cssprop {} --pf-c-label--m-gold--BackgroundColor {@default `#fdf7e7`} * @cssprop {} --pf-c-label--m-gold__content--before--BorderColor {@default `#f9e0a2`} * @cssprop {} --pf-c-label--m-outline--m-gold__content--Color {@default `#795600`} - * @cssprop {} --pf-c-label--m-blue__icon--Color {@default `#06c`} * @cssprop {} --pf-c-label--m-cyan__icon--Color {@default `#009596`} * @cssprop {} --pf-c-label--m-green__icon--Color {@default `#3e8635`} * @cssprop {} --pf-c-label--m-orange__icon--Color {@default `#ec7a08`} * @cssprop {} --pf-c-label--m-red__icon--Color {@default `#c9190b`} * @cssprop {} --pf-c-label--m-gold__icon--Color {@default `#f0ab00`} - * * @csspart icon - container for the label icon * @csspart close-button - container for removable labels' close button - * * @slot icon * Contains the labels's icon, e.g. web-icon-alert-success. - * * @slot * Must contain the text for the label. - * * @cssprop {} --pf-c-label--m-compact--PaddingTop {@default `0`} * @cssprop {} --pf-c-label--m-compact--PaddingRight {@default `0.5rem`} * @cssprop {} --pf-c-label--m-compact--PaddingBottom {@default `0`} * @cssprop {} --pf-c-label--m-compact--PaddingLeft {@default `0.5rem`} */ @customElement('pf-label') -export class PfLabel extends BaseLabel { - static readonly styles = [...BaseLabel.styles, styles]; +export class PfLabel extends LitElement { + static readonly styles = [styles]; - static readonly shadowRootOptions: ShadowRootInit = { ...BaseLabel.shadowRootOptions, delegatesFocus: true }; + static override readonly shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; /** * Changes the style of the label. * - Filled: Colored background with colored border. * - Outline: White background with colored border. */ - @property() variant: LabelVariant = 'filled'; + @property() variant: + | 'filled' + | 'outline' = 'filled'; /** * Changes the color of the label */ - @property() color: LabelColor = 'grey'; + @property() color: + | 'blue' + | 'cyan' + | 'green' + | 'orange' + | 'purple' + | 'red' + | 'grey' + | 'gold' = 'grey'; /** Shorthand for the `icon` slot, the value is icon name */ @property() icon?: string; @@ -144,34 +125,51 @@ export class PfLabel extends BaseLabel { /** Text label for a removable label's close button */ @property({ attribute: 'close-button-label' }) closeButtonLabel?: string; + /** Represents the state of the anonymous and icon slots */ + #slots = new SlotController(this, null, 'icon'); + override render() { const { compact, truncated } = this; + const { variant, color, icon } = this; + const hasIcon = !!icon || this.#slots.hasSlotted('icon'); return html` - ${super.render()} - `; - } - - protected override renderDefaultIcon() { - return !this.icon ? '' : html` - + + + + + + + + + + + + + `; } - protected override renderSuffix() { - return !this.removable ? '' : html` - - - - - - - - `; + #onClickClose() { + if (this.removable && this.dispatchEvent(new LabelCloseEvent())) { + this.remove(); + } } } +export type LabelVariant = PfLabel['variant']; + +export type LabelColor = PfLabel['color']; + declare global { interface HTMLElementTagNameMap { 'pf-label': PfLabel; diff --git a/elements/pf-label/test/pf-label.spec.ts b/elements/pf-label/test/pf-label.spec.ts index a21006dce7..b7866eba29 100644 --- a/elements/pf-label/test/pf-label.spec.ts +++ b/elements/pf-label/test/pf-label.spec.ts @@ -22,14 +22,6 @@ const exampleWithOutlineAttribute = html` Default Outline `; -const exampleWithIconAttribute = html` - Default Icon -`; - -const exampleWithIconAttributeEmpty = html` - Default -`; - const exampleWithCompactAttribute = html` Default Compact `; @@ -53,9 +45,9 @@ describe('', function() { const el = await createFixture(example); const klass = customElements.get('pf-label'); expect(el) - .to.be.an.instanceOf(klass) - .and - .to.be.an.instanceOf(PfLabel); + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfLabel); }); it('should display default variant', async function() { @@ -102,18 +94,34 @@ describe('', function() { expect(beforeStyles.getPropertyValue('border-color')).to.equal('rgb(210, 210, 210)'); }); - it('should render a pf-icon if the icon attribute is present and valid', async function() { - const el = await createFixture(exampleWithIconAttribute); - await el.updateComplete; - const icon = el.shadowRoot!.querySelector('pf-icon')!; - expect(icon.icon).to.equal(el.icon); + describe('with valid icon attribute', function() { + let element: PfLabel; + beforeEach(async function() { + element = await createFixture(html` + Default Icon + `); + element.updateComplete; + }); + it('should render a pf-icon', async function() { + const icon = element.shadowRoot!.querySelector('pf-icon')!; + expect(icon.hidden).to.be.false; + expect(icon.icon).to.equal(element.icon); + }); }); - it('should not render a pf-icon if the icon attribute is present but empty', async function() { - const el = await createFixture(exampleWithIconAttributeEmpty); - await el.updateComplete; - const icon = el.shadowRoot!.querySelector('pf-icon')!; - expect(icon).to.be.equal(null); + describe('with empty icon attribute', function() { + let element: PfLabel; + beforeEach(async function() { + element = await createFixture(html` + Default + `); + element.updateComplete; + }); + it('should not render a pf-icon', async function() { + const icon = element.shadowRoot!.querySelector('pf-icon')!; + expect(icon.hidden).to.be.true; + expect(icon.icon).to.be.undefined; + }); }); it('should display compact version if the attribute is-compact is present', async function() { diff --git a/elements/pf-modal/demo/custom-header-footer.html b/elements/pf-modal/demo/custom-header-footer.html new file mode 100644 index 0000000000..8701e84c4f --- /dev/null +++ b/elements/pf-modal/demo/custom-header-footer.html @@ -0,0 +1,59 @@ +
+ +

With custom header/footer content.

+

Allows for custom content in the header and/or footer by slotting HTML.

+

When static text describing the modal is available, it can be wrapped + with an ID referring to the + modal's aria-describedby value.

+

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis + aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

+ + Custom modal footer. +

+
+ Show modal +
+ + + + diff --git a/elements/pf-modal/demo/custom-icon.html b/elements/pf-modal/demo/custom-icon.html new file mode 100644 index 0000000000..27189df3e0 --- /dev/null +++ b/elements/pf-modal/demo/custom-icon.html @@ -0,0 +1,54 @@ +
+ +

+ + Modal Header +

+

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis + aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+ Show modal +
+ + + + diff --git a/elements/pf-modal/demo/demo.css b/elements/pf-modal/demo/demo.css deleted file mode 100644 index 7a6431fa49..0000000000 --- a/elements/pf-modal/demo/demo.css +++ /dev/null @@ -1,32 +0,0 @@ -@font-face { - font-family: "RedHatDisplayUpdated"; - src: url(https://patternfly.org/v4/fonts/RedHatDisplay-updated-Regular.woff2) format("woff2"); - font-style: normal; - font-weight: 300; - text-rendering: optimizeLegibility; -} -@font-face { - font-family: "RedHatDisplayUpdated"; - src: url(https://patternfly.org/v4/fonts/RedHatDisplay-updated-Medium.woff2) format("woff2"); - font-style: normal; - font-weight: 400; - text-rendering: optimizeLegibility; -} -@font-face { - font-family: "RedHatDisplayUpdated"; - src: url(https://patternfly.org/v4/fonts/RedHatDisplay-updated-Bold.woff2) format("woff2"); - font-style: normal; - font-weight: 700; - text-rendering: optimizeLegibility; -} - -:host { - --pf-modal--MinWidth: 800px; - --pf-modal--MaxWidth: 800px; - --pf-modal--MaxHeight: 800px; -} - -#external-lots::part(body) { - display: flex; - gap: 24px; -} diff --git a/elements/pf-modal/demo/description.html b/elements/pf-modal/demo/description.html new file mode 100644 index 0000000000..8db4de88d2 --- /dev/null +++ b/elements/pf-modal/demo/description.html @@ -0,0 +1,80 @@ +
+ +

Modal with description

+

A description is used when you want to provide more info about the modal than the title is + able to describe. The content in the description is static and will not scroll with the rest of the modal body. +

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum.

+

Nullam dapibus, leo quis suscipit semper, nisi sapien ultricies turpis, ac tincidunt ipsum risus in nibh. Ut + consequat dolor risus. Vivamus ultricies lacinia ipsum, mattis egestas nisi scelerisque sit amet. Fusce eleifend, + sapien vel tempor convallis, magna nisl dapibus tortor, vel lobortis metus turpis vel odio. Suspendisse pharetra + ex nec volutpat tristique. Cras posuere et augue id maximus. Duis lobortis rutrum luctus. Integer cursus odio ac + enim vehicula sollicitudin. Morbi feugiat urna nulla, vel molestie enim tempus et. Fusce pellentesque ligula a + nibh viverra mattis. Vestibulum tincidunt diam quis enim feugiat finibus in eu felis. Morbi ac fringilla ligula. + Praesent nec ex nec sapien laoreet suscipit. Nullam sit amet tempor metus, a consequat purus. Pellentesque non + maximus nulla, tempus accumsan enim.

+

Etiam semper metus sed urna blandit lacinia. Maecenas nibh nisl, pharetra elementum enim sed, porta vehicula + felis. Donec at ante consequat, aliquet urna vitae, imperdiet urna. Nam vel molestie nibh, quis ornare arcu. Donec + faucibus enim id accumsan laoreet. Sed laoreet leo id eleifend sagittis. Praesent eget lacinia lectus, sit amet + cursus eros. Aenean et augue risus.

+

Aliquam erat volutpat. Integer nisi justo, molestie a dolor ut, ultricies efficitur est. Lorem ipsum dolor sit + amet, consectetur adipiscing elit. In et diam dignissim, aliquet odio quis, efficitur erat. Integer cursus + convallis ligula, dapibus elementum sem. Mauris blandit vitae risus id pharetra. Donec et diam eros.

+

Curabitur urna est, mollis vitae leo nec, vehicula pharetra dui. Mauris non est viverra, semper lacus in, + sollicitudin est. Fusce pharetra neque vel orci congue dignissim. Curabitur ac sem viverra, molestie mauris ac, + egestas tortor. Aenean ut aliquet ligula, id gravida metus. Sed semper et quam et sagittis. Pellentesque egestas + magna id eros interdum facilisis. Nam dignissim ante quis augue finibus tristique. Donec at augue sem. Nulla + mollis risus at ligula finibus, vitae volutpat ex auctor. Morbi vel cursus felis. Maecenas lobortis porttitor + odio, non venenatis risus varius vitae. Proin gravida mi odio, sed pretium neque luctus vitae. Mauris ut libero + bibendum, finibus dui non, rutrum sapien. Quisque ac bibendum erat. Vestibulum tincidunt risus nisi.

+ Confirm + Cancel +
+ Show modal +
+ + + + diff --git a/elements/pf-modal/demo/no-header.html b/elements/pf-modal/demo/no-header.html new file mode 100644 index 0000000000..88393b031c --- /dev/null +++ b/elements/pf-modal/demo/no-header.html @@ -0,0 +1,49 @@ +
+ +

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis + aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+ Show modal +
+ + + + diff --git a/elements/pf-modal/demo/overflowing-content.html b/elements/pf-modal/demo/overflowing-content.html new file mode 100644 index 0000000000..8a4e790cc1 --- /dev/null +++ b/elements/pf-modal/demo/overflowing-content.html @@ -0,0 +1,79 @@ +
+

If the content that you're passing to the modal is likely to overflow the modal content area, it is still + accessible via keyboard scrolling.

+ +

Modal with overflowing content

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum.

+

Nullam dapibus, leo quis suscipit semper, nisi sapien ultricies turpis, ac tincidunt ipsum risus in nibh. Ut + consequat dolor risus. Vivamus ultricies lacinia ipsum, mattis egestas nisi scelerisque sit amet. Fusce eleifend, + sapien vel tempor convallis, magna nisl dapibus tortor, vel lobortis metus turpis vel odio. Suspendisse pharetra + ex nec volutpat tristique. Cras posuere et augue id maximus. Duis lobortis rutrum luctus. Integer cursus odio ac + enim vehicula sollicitudin. Morbi feugiat urna nulla, vel molestie enim tempus et. Fusce pellentesque ligula a + nibh viverra mattis. Vestibulum tincidunt diam quis enim feugiat finibus in eu felis. Morbi ac fringilla ligula. + Praesent nec ex nec sapien laoreet suscipit. Nullam sit amet tempor metus, a consequat purus. Pellentesque non + maximus nulla, tempus accumsan enim.

+

Etiam semper metus sed urna blandit lacinia. Maecenas nibh nisl, pharetra elementum enim sed, porta vehicula + felis. Donec at ante consequat, aliquet urna vitae, imperdiet urna. Nam vel molestie nibh, quis ornare arcu. Donec + faucibus enim id accumsan laoreet. Sed laoreet leo id eleifend sagittis. Praesent eget lacinia lectus, sit amet + cursus eros. Aenean et augue risus.

+

Aliquam erat volutpat. Integer nisi justo, molestie a dolor ut, ultricies efficitur est. Lorem ipsum dolor sit + amet, consectetur adipiscing elit. In et diam dignissim, aliquet odio quis, efficitur erat. Integer cursus + convallis ligula, dapibus elementum sem. Mauris blandit vitae risus id pharetra. Donec et diam eros.

+

Curabitur urna est, mollis vitae leo nec, vehicula pharetra dui. Mauris non est viverra, semper lacus in, + sollicitudin est. Fusce pharetra neque vel orci congue dignissim. Curabitur ac sem viverra, molestie mauris ac, + egestas tortor. Aenean ut aliquet ligula, id gravida metus. Sed semper et quam et sagittis. Pellentesque egestas + magna id eros interdum facilisis. Nam dignissim ante quis augue finibus tristique. Donec at augue sem. Nulla + mollis risus at ligula finibus, vitae volutpat ex auctor. Morbi vel cursus felis. Maecenas lobortis porttitor + odio, non venenatis risus varius vitae. Proin gravida mi odio, sed pretium neque luctus vitae. Mauris ut libero + bibendum, finibus dui non, rutrum sapien. Quisque ac bibendum erat. Vestibulum tincidunt risus nisi.

+ Confirm + Cancel +
+ Show modal +
+ + + + diff --git a/elements/pf-modal/demo/pf-modal.html b/elements/pf-modal/demo/pf-modal.html index 11fd877301..9574c1cad7 100644 --- a/elements/pf-modal/demo/pf-modal.html +++ b/elements/pf-modal/demo/pf-modal.html @@ -1,8 +1,4 @@ - - -
-

Basic

Simple modal header

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt @@ -16,234 +12,44 @@

Simple modal header

Show modal
-
-

With description

- -

Modal with description

-

A description is used when you want to provide more info about the modal than the title is - able to describe. The content in the description is static and will not scroll with the rest of the modal body. -

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum.

-

Nullam dapibus, leo quis suscipit semper, nisi sapien ultricies turpis, ac tincidunt ipsum risus in nibh. Ut - consequat dolor risus. Vivamus ultricies lacinia ipsum, mattis egestas nisi scelerisque sit amet. Fusce eleifend, - sapien vel tempor convallis, magna nisl dapibus tortor, vel lobortis metus turpis vel odio. Suspendisse pharetra - ex nec volutpat tristique. Cras posuere et augue id maximus. Duis lobortis rutrum luctus. Integer cursus odio ac - enim vehicula sollicitudin. Morbi feugiat urna nulla, vel molestie enim tempus et. Fusce pellentesque ligula a - nibh viverra mattis. Vestibulum tincidunt diam quis enim feugiat finibus in eu felis. Morbi ac fringilla ligula. - Praesent nec ex nec sapien laoreet suscipit. Nullam sit amet tempor metus, a consequat purus. Pellentesque non - maximus nulla, tempus accumsan enim.

-

Etiam semper metus sed urna blandit lacinia. Maecenas nibh nisl, pharetra elementum enim sed, porta vehicula - felis. Donec at ante consequat, aliquet urna vitae, imperdiet urna. Nam vel molestie nibh, quis ornare arcu. Donec - faucibus enim id accumsan laoreet. Sed laoreet leo id eleifend sagittis. Praesent eget lacinia lectus, sit amet - cursus eros. Aenean et augue risus.

-

Aliquam erat volutpat. Integer nisi justo, molestie a dolor ut, ultricies efficitur est. Lorem ipsum dolor sit - amet, consectetur adipiscing elit. In et diam dignissim, aliquet odio quis, efficitur erat. Integer cursus - convallis ligula, dapibus elementum sem. Mauris blandit vitae risus id pharetra. Donec et diam eros.

-

Curabitur urna est, mollis vitae leo nec, vehicula pharetra dui. Mauris non est viverra, semper lacus in, - sollicitudin est. Fusce pharetra neque vel orci congue dignissim. Curabitur ac sem viverra, molestie mauris ac, - egestas tortor. Aenean ut aliquet ligula, id gravida metus. Sed semper et quam et sagittis. Pellentesque egestas - magna id eros interdum facilisis. Nam dignissim ante quis augue finibus tristique. Donec at augue sem. Nulla - mollis risus at ligula finibus, vitae volutpat ex auctor. Morbi vel cursus felis. Maecenas lobortis porttitor - odio, non venenatis risus varius vitae. Proin gravida mi odio, sed pretium neque luctus vitae. Mauris ut libero - bibendum, finibus dui non, rutrum sapien. Quisque ac bibendum erat. Vestibulum tincidunt risus nisi.

- Confirm - Cancel -
- Show modal -
- -
-

Top aligned

- -

Top modal header

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum.

- Confirm - Cancel -
- Show modal -
- -
-

Small

- -

Small modal header

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum.

- Confirm - Cancel -
- Show modal -
- -
-

Medium

- -

Medium modal header

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum.

- Confirm - Cancel -
- Show modal -
- -
-

Large

- -

Large modal header

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum.

- Confirm - Cancel -
- Show modal -
- -
-

--pf-c-modal-box--Width CSS Custom Property

- -

Width 50% header

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum.

- Confirm - Cancel -
- Show modal -
- -
-

Custom header and footer

- -

With custom header/footer content.

-

Allows for custom content in the header and/or footer by slotting HTML.

-

When static text describing the modal is available, it can be wrapped - with an ID referring to the - modal's aria-describedby value.

-

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis - aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint - occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

-

- - Custom modal footer. -

-
- Show modal -
- -
-

No header

- -

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis - aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint - occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

-
- Show modal -
- -
-

Custom icon

- -

- - Modal Header -

-

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis - aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint - occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

-
- Show modal -
- -
-

Warning alert

- -

- - Modal Header -

-

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis - aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint - occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

-
- Show modal -
- -
-

With overflowing content

-

If the content that you're passing to the modal is likely to overflow the modal content area, it is still - accessible via keyboard scrolling.

- -

Modal with overflowing content

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum.

-

Nullam dapibus, leo quis suscipit semper, nisi sapien ultricies turpis, ac tincidunt ipsum risus in nibh. Ut - consequat dolor risus. Vivamus ultricies lacinia ipsum, mattis egestas nisi scelerisque sit amet. Fusce eleifend, - sapien vel tempor convallis, magna nisl dapibus tortor, vel lobortis metus turpis vel odio. Suspendisse pharetra - ex nec volutpat tristique. Cras posuere et augue id maximus. Duis lobortis rutrum luctus. Integer cursus odio ac - enim vehicula sollicitudin. Morbi feugiat urna nulla, vel molestie enim tempus et. Fusce pellentesque ligula a - nibh viverra mattis. Vestibulum tincidunt diam quis enim feugiat finibus in eu felis. Morbi ac fringilla ligula. - Praesent nec ex nec sapien laoreet suscipit. Nullam sit amet tempor metus, a consequat purus. Pellentesque non - maximus nulla, tempus accumsan enim.

-

Etiam semper metus sed urna blandit lacinia. Maecenas nibh nisl, pharetra elementum enim sed, porta vehicula - felis. Donec at ante consequat, aliquet urna vitae, imperdiet urna. Nam vel molestie nibh, quis ornare arcu. Donec - faucibus enim id accumsan laoreet. Sed laoreet leo id eleifend sagittis. Praesent eget lacinia lectus, sit amet - cursus eros. Aenean et augue risus.

-

Aliquam erat volutpat. Integer nisi justo, molestie a dolor ut, ultricies efficitur est. Lorem ipsum dolor sit - amet, consectetur adipiscing elit. In et diam dignissim, aliquet odio quis, efficitur erat. Integer cursus - convallis ligula, dapibus elementum sem. Mauris blandit vitae risus id pharetra. Donec et diam eros.

-

Curabitur urna est, mollis vitae leo nec, vehicula pharetra dui. Mauris non est viverra, semper lacus in, - sollicitudin est. Fusce pharetra neque vel orci congue dignissim. Curabitur ac sem viverra, molestie mauris ac, - egestas tortor. Aenean ut aliquet ligula, id gravida metus. Sed semper et quam et sagittis. Pellentesque egestas - magna id eros interdum facilisis. Nam dignissim ante quis augue finibus tristique. Donec at augue sem. Nulla - mollis risus at ligula finibus, vitae volutpat ex auctor. Morbi vel cursus felis. Maecenas lobortis porttitor - odio, non venenatis risus varius vitae. Proin gravida mi odio, sed pretium neque luctus vitae. Mauris ut libero - bibendum, finibus dui non, rutrum sapien. Quisque ac bibendum erat. Vestibulum tincidunt risus nisi.

- Confirm - Cancel -
- Show modal -
- -
-

External trigger

-

You may set an external button as the trigger by setting its' ID as the modal's trigger attribute.

- -

External trigger

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit - anim id est laborum.

- Confirm - Cancel -
- -

Arbitrary content can intervene between the external trigger and the modal element, as long as they are within the - same root.

- - Show modal with external trigger -
- + + + diff --git a/elements/pf-modal/demo/pf-modal.js b/elements/pf-modal/demo/pf-modal.js deleted file mode 100644 index a8b1c7ac38..0000000000 --- a/elements/pf-modal/demo/pf-modal.js +++ /dev/null @@ -1,10 +0,0 @@ -import '@patternfly/elements/pf-card/pf-card.js'; -import '@patternfly/elements/pf-button/pf-button.js'; -import '@patternfly/elements/pf-modal/pf-modal.js'; -import '@patternfly/elements/pf-icon/pf-icon.js'; - -for (const button of document.querySelectorAll('pf-modal pf-button:not([slot="trigger"])')) { - button.addEventListener('click', e => { - e.target.closest('pf-modal').close(e.target.textContent.toLowerCase()); - }); -} diff --git a/elements/pf-modal/demo/styling.html b/elements/pf-modal/demo/styling.html new file mode 100644 index 0000000000..1b5bfaa3bf --- /dev/null +++ b/elements/pf-modal/demo/styling.html @@ -0,0 +1,56 @@ +
+

--pf-c-modal-box--Width CSS Custom Property

+ +

Width 50% header

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum.

+ Confirm + Cancel +
+ Show modal +
+ + + + diff --git a/elements/pf-modal/demo/top-aligned.html b/elements/pf-modal/demo/top-aligned.html new file mode 100644 index 0000000000..32ec70ae0c --- /dev/null +++ b/elements/pf-modal/demo/top-aligned.html @@ -0,0 +1,56 @@ +
+

Top aligned

+ +

Top modal header

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum.

+ Confirm + Cancel +
+ Show modal +
+ + + + diff --git a/elements/pf-modal/demo/trigger-attribute.html b/elements/pf-modal/demo/trigger-attribute.html new file mode 100644 index 0000000000..e26fe0e851 --- /dev/null +++ b/elements/pf-modal/demo/trigger-attribute.html @@ -0,0 +1,60 @@ +
+

You may set an external button as the trigger by setting its' ID as the modal's trigger attribute.

+ +

External trigger

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum.

+ Confirm + Cancel +
+ +

Arbitrary content can intervene between the external trigger and the modal element, as long as they are within the + same root.

+ + Show modal with external trigger +
+ + + + diff --git a/elements/pf-modal/demo/variants.html b/elements/pf-modal/demo/variants.html new file mode 100644 index 0000000000..1b89ddea75 --- /dev/null +++ b/elements/pf-modal/demo/variants.html @@ -0,0 +1,86 @@ +
+

Small

+ +

Small modal header

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum.

+ Confirm + Cancel +
+ Show modal +
+ +
+

Medium

+ +

Medium modal header

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum.

+ Confirm + Cancel +
+ Show modal +
+ +
+

Large

+ +

Large modal header

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum.

+ Confirm + Cancel +
+ Show modal +
+ + + + diff --git a/elements/pf-modal/demo/warning-alert.html b/elements/pf-modal/demo/warning-alert.html new file mode 100644 index 0000000000..f2a55e6ed1 --- /dev/null +++ b/elements/pf-modal/demo/warning-alert.html @@ -0,0 +1,54 @@ +
+ +

+ + Modal Header +

+

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis + aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+ Show modal +
+ + + + diff --git a/elements/pf-modal/pf-modal.ts b/elements/pf-modal/pf-modal.ts index 19762932a0..866a9c95ef 100644 --- a/elements/pf-modal/pf-modal.ts +++ b/elements/pf-modal/pf-modal.ts @@ -6,7 +6,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { classMap } from 'lit/directives/class-map.js'; import { ComposedEvent } from '@patternfly/pfe-core'; -import { bound, deprecation, initializer, observed } from '@patternfly/pfe-core/decorators.js'; +import { bound, initializer, observed } from '@patternfly/pfe-core/decorators.js'; import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; @@ -37,16 +37,12 @@ export class ModalOpenEvent extends ComposedEvent { /** * A **modal** displays important information to a user without requiring them to navigate * to a new page. - * * @summary Displays information or helps a user focus on a task - * * @slot - The default slot can contain any type of content. When the header is not present this unnamed slot appear at the top of the modal window (to the left of the close button). Otherwise it will appear beneath the header. * @slot header - The header is an optional slot that appears at the top of the modal window. It should be a header tag (h2-h6). * @slot footer - Optional footer content. Good place to put action buttons. - * * @fires {ModalOpenEvent} open - Fires when a user clicks on the trigger or manually opens a modal. * @fires {ModalCloseEvent} close - Fires when either a user clicks on either the close button or the overlay or manually closes a modal. - * * @csspart overlay - The modal overlay which lies under the dialog and above the page body * @csspart dialog - The dialog element * @csspart content - The container for the dialog content @@ -54,7 +50,6 @@ export class ModalOpenEvent extends ComposedEvent { * @csspart description - The container for the optional dialog description in the header * @csspart close-button - The modal's close button * @csspart footer - Actions footer container - * * @cssprop {} --pf-c-modal-box--ZIndex {@default 500} * @cssprop {} --pf-c-modal-box--Width - Width of the modal {@default calc(100% - 2rem)} * @cssprop {} --pf-c-modal-box--MaxWidth - Max width of the modal {@default calc(100% - 2rem)} @@ -72,7 +67,10 @@ export class ModalOpenEvent extends ComposedEvent { */ @customElement('pf-modal') export class PfModal extends LitElement implements HTMLDialogElement { - static readonly shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }; + static override readonly shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; static readonly styles = [style]; @@ -85,8 +83,6 @@ export class PfModal extends LitElement implements HTMLDialogElement { */ @property({ reflect: true }) variant?: 'small' | 'medium' | 'large'; - @deprecation({ alias: 'variant', attribute: 'width' }) width?: 'small' | 'medium' | 'large'; - /** * `position="top"` aligns the dialog with the top of the page */ @@ -222,7 +218,8 @@ export class PfModal extends LitElement implements HTMLDialogElement { protected _triggerChanged() { if (this.trigger) { - this.#triggerElement = (this.getRootNode() as Document | ShadowRoot).getElementById(this.trigger); + this.#triggerElement = (this.getRootNode() as Document | ShadowRoot) + .getElementById(this.trigger); this.#triggerElement?.addEventListener('click', this.onTriggerClick); } } @@ -238,7 +235,7 @@ export class PfModal extends LitElement implements HTMLDialogElement { if (open) { const path = event.composedPath(); const { closeOnOutsideClick } = this.constructor as typeof PfModal; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (closeOnOutsideClick && path.includes(overlay!) && !path.includes(dialog!)) { event.preventDefault(); this.cancel(); diff --git a/elements/pf-modal/test/pf-modal.spec.ts b/elements/pf-modal/test/pf-modal.spec.ts index 957d43e0bf..c1115ca940 100644 --- a/elements/pf-modal/test/pf-modal.spec.ts +++ b/elements/pf-modal/test/pf-modal.spec.ts @@ -10,21 +10,21 @@ const TEMPLATES = { testElement: html``, smallModal: html` - +

Small modal

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

`, mediumModal: html` - +

Medium modal

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

`, largeModal: html` - +

Large modal

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

@@ -40,9 +40,9 @@ describe('', function() { it('should upgrade', async function() { const el = await createFixture(TEMPLATES.testElement); expect(el, 'pf-modal should be an instance of PfModal') - .to.be.an.instanceOf(customElements.get('pf-modal')) - .and - .to.be.an.instanceOf(PfModal); + .to.be.an.instanceOf(customElements.get('pf-modal')) + .and + .to.be.an.instanceOf(PfModal); }); // Example test. @@ -64,24 +64,6 @@ describe('', function() { expect(button.getAttribute('aria-label'), 'button aria-label').to.equal('Close dialog'); }); - it('should apply attributes for deprecated slots', async function() { - // Use the same markup that's declared at the top of the file. - const el = await createFixture(html` - -

Modal with a header

-

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

-
- `); - const modalWindow = el.shadowRoot!.querySelector('#dialog')!; - const button = el.shadowRoot!.querySelector('[part=close-button]')!; - - await nextFrame(); - - expect(modalWindow.getAttribute('tabindex'), 'modal__window tabindex').to.equal('0'); - expect(modalWindow.hasAttribute('hidden'), 'hidden').to.be.true; - expect(button.getAttribute('aria-label'), 'button aria-label').to.equal('Close dialog'); - }); - it('should open the modal window when the trigger is clicked', async function() { const el = await createFixture(html` @@ -118,30 +100,30 @@ describe('', function() { await setViewport({ width: 1600, height: 1200 }); }); - describe('with width=small attribute', function() { + describe('with variant=small attribute', function() { it('has small modal width', async function() { const el = await createFixture(TEMPLATES.smallModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); - describe('with width=medium attribute', function() { + describe('with variant=medium attribute', function() { it('has medium modal width', async function() { const el = await createFixture(TEMPLATES.mediumModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); - describe('with width=large attribute', function() { + describe('with variant=large attribute', function() { it('has large modal width', async function() { const el = await createFixture(TEMPLATES.largeModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); }); @@ -151,30 +133,30 @@ describe('', function() { await setViewport({ width: 1000, height: 800 }); }); - describe('with width=small attribute', function() { + describe('with variant=small attribute', function() { it('has small modal width', async function() { const el = await createFixture(TEMPLATES.smallModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); - describe('with width=medium attribute', function() { + describe('with variant=medium attribute', function() { it('has medium modal width', async function() { const el = await createFixture(TEMPLATES.mediumModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); - describe('with width=large attribute', function() { + describe('with variant=large attribute', function() { it('has large modal width', async function() { const el = await createFixture(TEMPLATES.largeModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); }); @@ -184,30 +166,30 @@ describe('', function() { await setViewport({ width: 768, height: 600 }); }); - describe('with width=small attribute', function() { + describe('with variant=small attribute', function() { it('has small modal width', async function() { const el = await createFixture(TEMPLATES.smallModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); - describe('with width=medium attribute', function() { + describe('with variant=medium attribute', function() { it('has medium modal width', async function() { const el = await createFixture(TEMPLATES.mediumModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); - describe('with width=large attribute', function() { + describe('with variant=large attribute', function() { it('has large modal width', async function() { const el = await createFixture(TEMPLATES.largeModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); }); @@ -217,30 +199,30 @@ describe('', function() { await setViewport({ width: 480, height: 540 }); }); - describe('with width=small attribute', function() { + describe('with variant=small attribute', function() { it('has small modal width', async function() { const el = await createFixture(TEMPLATES.smallModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); - describe('with width=medium attribute', function() { + describe('with variant=medium attribute', function() { it('has medium modal width', async function() { const el = await createFixture(TEMPLATES.mediumModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); - describe('with width=large attribute', function() { + describe('with variant=large attribute', function() { it('has large modal width', async function() { const el = await createFixture(TEMPLATES.largeModal); const modalWindow = el.shadowRoot!.querySelector('#dialog')!; expect(getComputedStyle(modalWindow).getPropertyValue('max-width')) - .to.equal('calc(100% - 32px)'); + .to.equal('calc(100% - 32px)'); }); }); }); diff --git a/elements/pf-panel/demo/bordered.html b/elements/pf-panel/demo/bordered.html new file mode 100644 index 0000000000..5dc10860a0 --- /dev/null +++ b/elements/pf-panel/demo/bordered.html @@ -0,0 +1,15 @@ +
+ +

Main content

+
+
+ + + + diff --git a/elements/pf-panel/demo/demo.css b/elements/pf-panel/demo/demo.css deleted file mode 100644 index 801b5f06d3..0000000000 --- a/elements/pf-panel/demo/demo.css +++ /dev/null @@ -1,7 +0,0 @@ -:host { - display: block; -} - -main { - padding: 1rem; -} diff --git a/elements/pf-panel/demo/footer.html b/elements/pf-panel/demo/footer.html new file mode 100644 index 0000000000..94af2061e3 --- /dev/null +++ b/elements/pf-panel/demo/footer.html @@ -0,0 +1,16 @@ +
+ +

Main content

+

Footer content

+
+
+ + + + diff --git a/elements/pf-panel/demo/header-and-footer.html b/elements/pf-panel/demo/header-and-footer.html new file mode 100644 index 0000000000..c46e267126 --- /dev/null +++ b/elements/pf-panel/demo/header-and-footer.html @@ -0,0 +1,17 @@ +
+ +

Header content

+

Main content

+

Footer content

+
+
+ + + + diff --git a/elements/pf-panel/demo/header.html b/elements/pf-panel/demo/header.html new file mode 100644 index 0000000000..ad13b9b5bb --- /dev/null +++ b/elements/pf-panel/demo/header.html @@ -0,0 +1,16 @@ +
+ +

Header content

+

Main content

+
+
+ + + + diff --git a/elements/pf-panel/demo/pf-panel.html b/elements/pf-panel/demo/pf-panel.html index ad5ef87e13..787dbf9bcb 100644 --- a/elements/pf-panel/demo/pf-panel.html +++ b/elements/pf-panel/demo/pf-panel.html @@ -1,151 +1,15 @@ - - - -
-

Basic

- -

Main content

-
-
- -
-

Header

- -

Header content

-

Main content

-
-
-
-

Footer

Main content

-

Footer content

-
-

Header and footer

- -

Header content

-

Main content

-

Footer content

-
-
- -
-

Raised

- -

Main content

-
-
- -
-

Bordered

- -

Main content

-
-
- -
-

Scrollable

- -

- A couple of years ago the Computer Science Club at Bishop's University - ran into a problem. Our student run computer lab was running - unlicensed copies of a propriety operating system. The computers also - had many unlicensed programs installed. It was a big mess. At that - time we had to make an ethical decision. We had to decide whether we - wanted to continue breaking the law or not. We decided against running - software for which we didn't have licenses as it could lead to the lab - being closed. -

- -

- Once that was settled we were left with another decision. We could - either do fund raising and purchase propriety software legally or use - a Free software system like GNU/Linux which is usually available at no - cost. After calculating the expense of purchasing propriety software - licenses for 6 workstations we came to the realization that we just - didn't have $2,000+ to spend on software when a no cost solution was - available. -

- -

- Switching all of the systems over to GNU/Linux was one of the best - things that we've ever done. Besides having a legal lab, we also got a - lot of advanced features that the proprietary software we were using - lacked. We can update all of the machines from one location. We can - easily share a printer and configure the software to limit its usage - so that no one particular user can use up all of our toner. - Centralized logins and home directories mean that you can sit down at - any of our 6 workstations and have access to all of your files and - program settings. Free software does everything that we need. -

- -

- While there were a lot of tangible benefits, switching to Free - Software also has some non-tangible benefits. If someone new comes - into the lab and likes what they see, we have the freedom to be good - neighbors and share copies of the software. Being friendly and sharing - helps society. We are also free to browse and modify the source code - for the programs that we run. Being able to read large, complete, - well written computer code gives us a chance to learn from others. It - also gives us another level of customization that proprietary software - just can't offer. -

- -

- Thomas Cort, President, Bishop's University Computer Science Club -

-
-
- -
-

Scrollable with header and footer

- -

Header content

-

- Free software is absolutely critical to the way I perform daily - computer tasks. In particular, I have grown accustomed to the high - level of configurability and the degree of interoperability found in - free software. With free software, I find that I get the benefits one - might see when using solutions from a single large software vendor - (consistency in installation and configuration, interoperability, - etc.) without any of the drawbacks (vendor "lock-in" chief among - them). Of course, free software isn't developed by a single vendor; - these benefits arise from the openness of free software, and I find - this openness very refreshing. -

- -

- In my academic research in computer science, I use free software - almost exclusively. I find that it is necessary in my compiler, - middleware, and operating systems research to be able to study and - tinker with the internals of existing systems and yet be free to - openly publish my conclusions. This is often not possible or - permitted with proprietary offerings. I often release products of my - research as free software, to allow others the same capacity to study - my work. -

- -

- In my experience, free software does precisely what technology is - supposed to do: it enables me to perform tasks. Non-free software - doesn't always rise to this definition of "technology." Proprietary - data formats can and do lock away data. Proprietary software can give - nasty surprises when it decides incorrectly that you're manipulating - data improperly (as, for example, when it suspects you of copyright - infringement, even when your actions are legitimate and legal). Such - software turns technology on its head: it disables you rather than - enabling you. I have not the patience to fight with technology; I - need it to be on my side. -

- -

- Morgan Deters
-

-

Footer content

-
-
+ + diff --git a/elements/pf-panel/demo/pf-panel.js b/elements/pf-panel/demo/pf-panel.js deleted file mode 100644 index 3d6ba5fc80..0000000000 --- a/elements/pf-panel/demo/pf-panel.js +++ /dev/null @@ -1 +0,0 @@ -import '@patternfly/elements/pf-panel/pf-panel.js'; diff --git a/elements/pf-panel/demo/raised.html b/elements/pf-panel/demo/raised.html new file mode 100644 index 0000000000..e69b7fc0f4 --- /dev/null +++ b/elements/pf-panel/demo/raised.html @@ -0,0 +1,15 @@ +
+ +

Main content

+
+
+ + + + diff --git a/elements/pf-panel/demo/scrollable-with-header-and-footer.html b/elements/pf-panel/demo/scrollable-with-header-and-footer.html new file mode 100644 index 0000000000..ca0f4c4c18 --- /dev/null +++ b/elements/pf-panel/demo/scrollable-with-header-and-footer.html @@ -0,0 +1,56 @@ +
+ +

Header content

+

+ Free software is absolutely critical to the way I perform daily + computer tasks. In particular, I have grown accustomed to the high + level of configurability and the degree of interoperability found in + free software. With free software, I find that I get the benefits one + might see when using solutions from a single large software vendor + (consistency in installation and configuration, interoperability, + etc.) without any of the drawbacks (vendor "lock-in" chief among + them). Of course, free software isn't developed by a single vendor; + these benefits arise from the openness of free software, and I find + this openness very refreshing. +

+ +

+ In my academic research in computer science, I use free software + almost exclusively. I find that it is necessary in my compiler, + middleware, and operating systems research to be able to study and + tinker with the internals of existing systems and yet be free to + openly publish my conclusions. This is often not possible or + permitted with proprietary offerings. I often release products of my + research as free software, to allow others the same capacity to study + my work. +

+ +

+ In my experience, free software does precisely what technology is + supposed to do: it enables me to perform tasks. Non-free software + doesn't always rise to this definition of "technology." Proprietary + data formats can and do lock away data. Proprietary software can give + nasty surprises when it decides incorrectly that you're manipulating + data improperly (as, for example, when it suspects you of copyright + infringement, even when your actions are legitimate and legal). Such + software turns technology on its head: it disables you rather than + enabling you. I have not the patience to fight with technology; I + need it to be on my side. +

+ +

+ Morgan Deters
+

+

Footer content

+
+
+ + + + diff --git a/elements/pf-panel/pf-panel.ts b/elements/pf-panel/pf-panel.ts index d5abef1c11..03e838cdae 100644 --- a/elements/pf-panel/pf-panel.ts +++ b/elements/pf-panel/pf-panel.ts @@ -11,7 +11,6 @@ import styles from './pf-panel.css'; * be used to house other components such as fields, forms, videos, buttons, and more. * The panel should not be confused with the [drawer](https://www.patternfly.org/v4/components/drawer/design-guidelines/) * component, which allows you to surface information via a collapsable container. - * * @slot header - Place header content here * @slot - Place main content here * @slot footer - Place footer content here @@ -30,10 +29,14 @@ export class PfPanel extends LitElement { const hasHeader = this.#slots.hasSlotted('header'); const hasFooter = this.#slots.hasSlotted('footer'); return html` - +
+ +

- +
+ +
`; } } diff --git a/elements/pf-panel/test/pf-panel.spec.ts b/elements/pf-panel/test/pf-panel.spec.ts index cb38b52d74..ea57ea708d 100644 --- a/elements/pf-panel/test/pf-panel.spec.ts +++ b/elements/pf-panel/test/pf-panel.spec.ts @@ -16,9 +16,9 @@ describe('', function() { it('should upgrade', function() { const klass = customElements.get('pf-panel'); expect(element) - .to.be.an.instanceOf(klass) - .and - .to.be.an.instanceOf(PfPanel); + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfPanel); }); }); }); diff --git a/elements/pf-popover/demo/alert.html b/elements/pf-popover/demo/alert.html new file mode 100644 index 0000000000..3abf09ca5d --- /dev/null +++ b/elements/pf-popover/demo/alert.html @@ -0,0 +1,68 @@ +
+
+ Popover alert severity + + + + + + + Toggle popover + +
+
+ + + + diff --git a/elements/pf-popover/demo/content.html b/elements/pf-popover/demo/content.html new file mode 100644 index 0000000000..4ba3e0be62 --- /dev/null +++ b/elements/pf-popover/demo/content.html @@ -0,0 +1,65 @@ +
+
+ Content attributes + + Toggle popover + +
+ +
+ Content slots + +

Popover heading

+
+ Popovers are triggered by click rather than hover. +
+ Popover footer + Toggle popover +
+
+ +
+ Custom Heading Level + + Toggle popover + +
+
+ + + + diff --git a/elements/pf-popover/demo/demo.css b/elements/pf-popover/demo/demo.css deleted file mode 100644 index 13fcaf0d8f..0000000000 --- a/elements/pf-popover/demo/demo.css +++ /dev/null @@ -1,47 +0,0 @@ -form { - padding: 0 2rem; - display: flex; - flex-flow: row wrap; - gap: 8px; -} - -form h2 { - width: 100%; -} - -label { - display: flex; - flex-direction: column; -} - -fieldset label { - display: grid; - align-content: center; - display: contents; -} - -select { - display: block; - margin-inline-end: auto; - flex-basis: 100%; -} - -#position-select { - margin: 1rem 0; -} - -#alert-select { - margin-bottom: 1rem; -} - -#no-padding pf-popover::part(content) { - --pf-c-popover__content--PaddingTop: 0px; - --pf-c-popover__content--PaddingRight: 0px; - --pf-c-popover__content--PaddingBottom: 0px; - --pf-c-popover__content--PaddingLeft: 0px; -} - -#auto-width pf-popover::part(content) { - --pf-c-popover--MaxWidth: none; - --pf-c-popover--MinWidth: auto; -} diff --git a/elements/pf-popover/demo/edge-of-viewport.html b/elements/pf-popover/demo/edge-of-viewport.html new file mode 100644 index 0000000000..62ed4bad7b --- /dev/null +++ b/elements/pf-popover/demo/edge-of-viewport.html @@ -0,0 +1,29 @@ +

+ When the viewport is 1190 pixels wide, (such that the "toggle popover" button appears + at the far edge of the viewport, the popover at the far end of the viewport + must not cause a horizontal scrollbar which would otherwise not have been there. +

+Spacer +Spacer +Spacer +Spacer +Spacer +Spacer +Spacer +Spacer +Spacer +Spacer +Spacer +Spacer + +

In this popover, the content is quite wide, + so as to force a horizontal scrollbar if the + dialog content is improperly rendered.

+ Toggle popover +
+ + + diff --git a/elements/pf-popover/demo/flip.html b/elements/pf-popover/demo/flip.html index 6d94889b6d..11e9a0d1ce 100644 --- a/elements/pf-popover/demo/flip.html +++ b/elements/pf-popover/demo/flip.html @@ -1,6 +1,3 @@ - - -
No flip @@ -39,3 +36,82 @@
+ + + + diff --git a/elements/pf-popover/demo/icons.html b/elements/pf-popover/demo/icons.html new file mode 100644 index 0000000000..f29104ed8f --- /dev/null +++ b/elements/pf-popover/demo/icons.html @@ -0,0 +1,53 @@ +
+
+ Icon attribute + + Toggle popover + +
+ +
+ Icon slot + + +

Popover with icon

+
Popovers are triggered by click rather than hover.
+
Popover footer
+ Toggle popover +
+
+ +
+ Custom Icon Set + + +
Popover heading
+
+ Popovers are triggered by click rather than hover. +
+ Popover footer + Toggle popover +
+
+
+ + + + diff --git a/elements/pf-popover/demo/pf-popover.html b/elements/pf-popover/demo/pf-popover.html index e36c6a8578..3a2dea0bc7 100644 --- a/elements/pf-popover/demo/pf-popover.html +++ b/elements/pf-popover/demo/pf-popover.html @@ -1,40 +1,4 @@ - - -
-

Basic

- -
- Content attributes - - Toggle popover - -
- -
- Content slots - -

Popover heading

-
- Popovers are triggered by click rather than hover. -
- Popover footer - Toggle popover -
-
- -
- Custom Heading Level - - Toggle popover - -
-
Triggered by reference Popover heading Toggle popover
-
- No flip - - Toggle popover - -
-
No hide on outside click Popover heading
-
- Flip fallback - - - Toggle popover - -
-
Close popover from content @@ -123,58 +50,42 @@

Popover heading

Toggle popover
- -

Popover with icon in the header

- -
- Icon attribute - - Toggle popover - -
- -
- Icon slot - - -

Popover with icon

-
Popovers are triggered by click rather than hover.
-
Popover footer
- Toggle popover -
-
- -
- Custom Icon Set - - -
Popover heading
-
- Popovers are triggered by click rather than hover. -
- Popover footer - Toggle popover -
-
-
-
- Popover alert severity - - - - - - - Toggle popover - -
-
+ + + diff --git a/elements/pf-popover/demo/pf-popover.js b/elements/pf-popover/demo/pf-popover.js deleted file mode 100644 index 0a7b7ed7bf..0000000000 --- a/elements/pf-popover/demo/pf-popover.js +++ /dev/null @@ -1,26 +0,0 @@ -import '@patternfly/elements/pf-popover/pf-popover.js'; -import '@patternfly/elements/pf-button/pf-button.js'; -import '@patternfly/elements/pf-icon/pf-icon.js'; - -// Positions -const select = document.getElementById('position-select'); -select.addEventListener('change', event => - event.target - .closest('fieldset') - .querySelector('pf-popover') - .setAttribute('position', event.target.value)); - -// Close popover from content -const closeButton = document.getElementById('close-button'); -closeButton?.addEventListener('click', event => event.target.closest('pf-popover').hide()); - -// Alert variants -const alert = document.getElementById('alert'); -alert?.addEventListener('change', event => - alert - .querySelector('pf-popover') - .setAttribute('alert-severity', event.target.closest('form').elements.severity.value)); - -document.addEventListener('submit', function(event) { - event.preventDefault(); -}); diff --git a/elements/pf-popover/pf-popover.ts b/elements/pf-popover/pf-popover.ts index be5dd10262..c739d4410b 100644 --- a/elements/pf-popover/pf-popover.ts +++ b/elements/pf-popover/pf-popover.ts @@ -12,6 +12,7 @@ import { ComposedEvent, StringListConverter } from '@patternfly/pfe-core/core.js import type { Placement } from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; import '@patternfly/elements/pf-button/pf-button.js'; import styles from './pf-popover.css'; +import { deprecation } from '@patternfly/pfe-core/decorators/deprecation.js'; const headingLevels = [2, 3, 4, 5, 6] as const; @@ -45,9 +46,7 @@ export class PopoverShownEvent extends ComposedEvent { /** * A **Popover** displays content in a non-modal dialog and adds contextual information or provides resources via text and links. - * * @summary Toggle the visibility of helpful or contextual information. - * * @slot - * The default slot holds invoking element. * Typically this would be an icon, button, or other small sized element. @@ -61,7 +60,6 @@ export class PopoverShownEvent extends ComposedEvent { * This slot renders the content that will be displayed inside of the body of the popover. * @slot footer * This slot renders the content that will be displayed inside of the footer of the popover. - * * @csspart container - The component wrapper * @csspart content - The content wrapper * @csspart header - The header element; only visible if both an icon annd heading are provided. @@ -70,7 +68,6 @@ export class PopoverShownEvent extends ComposedEvent { * @csspart close-button - The close button * @csspart body - The container for the body content * @csspart footer - The container for the footer content - * * @cssprop {} --pf-c-popover__arrow--Height * Height of the arrow * {@default `1.5625rem`} @@ -258,7 +255,11 @@ export class PfPopover extends LitElement { /** * The heading level to use for the popover's header. The default is `h6`. */ - @property({ type: Number, reflect: true, attribute: 'heading-level' }) headingLevel?: HeadingLevel; + @property({ + type: Number, + reflect: true, + attribute: 'heading-level', + }) headingLevel?: HeadingLevel; /** * Indicates which icon set to use for the header's icon. @@ -280,7 +281,15 @@ export class PfPopover extends LitElement { /** * The accessible label for the popover's close button. The default is `Close popover`. */ - @property({ reflect: true, attribute: 'close-label' }) closeButtonLabel?: string; + @property({ reflect: true, attribute: 'accessible-close-label' }) accessibleCloseLabel?: string; + + /** + * @deprecated do not use the color-palette attribute, which was added by mistake. use context-providing containers (e.g. rh-card) instead + */ + @deprecation({ + alias: 'accessible-close-label', + attribute: 'close-label', + }) closeButtonLabel?: string; /** * The text announced by the screen reader to indicate the popover's severity. @@ -306,6 +315,9 @@ export class PfPopover extends LitElement { @query('#trigger') private _slottedTrigger?: HTMLElement | null; @query('#arrow') private _arrow!: HTMLDivElement; + /** True before the show animation begins and after the hide animation ends */ + #hideDialog = true; + #referenceTrigger?: HTMLElement | null = null; #float = new FloatingDOMController(this, { @@ -316,9 +328,9 @@ export class PfPopover extends LitElement { #slots = new SlotController(this, null, 'icon', 'heading', 'body', 'footer'); - connectedCallback() { - super.connectedCallback(); - this.addEventListener('keydown', this.onKeydown); + constructor() { + super(); + this.addEventListener('keydown', this.#onKeydown); } render() { @@ -340,19 +352,9 @@ export class PfPopover extends LitElement { ${headingContent} `; - const header = !(hasHeading && hasIcon) ? headingSlotWithFallback : html` -
- - - - - ${!this.alertSeverity ? nothing : html` - ${this.alertSeverityText ?? `${this.alertSeverity} alert:`}`} - ${headingSlotWithFallback} -
- `; + const headerIcon = this.icon + ?? PfPopover.alertIcons.get(this.alertSeverity as AlertSeverity) + ?? ''; return html`
- + @keydown="${this.#onKeydown}" + @click="${this.toggle}"> +
- ${header} + ${!(hasHeading && hasIcon) ? headingSlotWithFallback : html` +
+ + + + + ${!this.alertSeverity ? nothing : html` + ${this.alertSeverityText ?? `${this.alertSeverity} alert:`}`} + ${headingSlotWithFallback} +
`} ${this.body ?? ''}
${this.footer} @@ -391,7 +408,7 @@ export class PfPopover extends LitElement { super.disconnectedCallback(); PfPopover.instances.delete(this); this.#referenceTrigger?.removeEventListener('click', this.toggle); - this.#referenceTrigger?.removeEventListener('keydown', this.onKeydown); + this.#referenceTrigger?.removeEventListener('keydown', this.#onKeydown); } #getReferenceTrigger() { @@ -399,19 +416,18 @@ export class PfPopover extends LitElement { return !this.trigger ? null : root.getElementById(this.trigger); } - #triggerChanged() { const oldReferenceTrigger = this.#referenceTrigger; this.#referenceTrigger = this.#getReferenceTrigger(); if (oldReferenceTrigger !== this.#referenceTrigger) { oldReferenceTrigger?.removeEventListener('click', this.toggle); - oldReferenceTrigger?.removeEventListener('keydown', this.onKeydown); + oldReferenceTrigger?.removeEventListener('keydown', this.#onKeydown); this.#referenceTrigger?.addEventListener('click', this.toggle); - this.#referenceTrigger?.addEventListener('keydown', this.onKeydown); + this.#referenceTrigger?.addEventListener('keydown', this.#onKeydown); } } - @bound private onKeydown(event: KeyboardEvent) { + #onKeydown = (event: KeyboardEvent) => { switch (event.key) { case 'Escape': case 'Esc': @@ -425,7 +441,7 @@ export class PfPopover extends LitElement { } return; } - } + }; #outsideClick(event: MouseEvent) { const path = event.composedPath(); @@ -455,6 +471,8 @@ export class PfPopover extends LitElement { * Opens the popover */ @bound async show() { + this.#hideDialog = false; + this.requestUpdate(); this.dispatchEvent(new PopoverShowEvent()); await this.updateComplete; await this.#float.show({ @@ -477,6 +495,8 @@ export class PfPopover extends LitElement { this._popover?.close(); this.dispatchEvent(new PopoverHiddenEvent()); PfPopover.instances.delete(this); + this.#hideDialog = true; + this.requestUpdate(); } } diff --git a/elements/pf-popover/test/pf-popover.spec.ts b/elements/pf-popover/test/pf-popover.spec.ts index 725d051eda..72ef5614a7 100644 --- a/elements/pf-popover/test/pf-popover.spec.ts +++ b/elements/pf-popover/test/pf-popover.spec.ts @@ -1,13 +1,10 @@ -import { expect, html, fixture, fixtureCleanup } from '@open-wc/testing'; +import { expect, html, fixture, fixtureCleanup, nextFrame } from '@open-wc/testing'; import { a11ySnapshot, type A11yTreeSnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; import { clickElementAtCenter } from '@patternfly/pfe-tools/test/utils.js'; import { sendKeys, resetMouse } from '@web/test-runner-commands'; import { PfPopover } from '@patternfly/elements/pf-popover/pf-popover.js'; import { PfButton } from '@patternfly/elements/pf-button/pf-button.js'; -const takeProps = (props: string[]) => (obj: object) => - Object.fromEntries(Object.entries(obj).filter(([k]) => props.includes(k))); - function press(key: string) { return async function() { await sendKeys({ press: key }); @@ -22,17 +19,6 @@ describe('', function() { element = await fixture(html``); } - /** create a test fixture with slotted trigger and content attrs */ - async function setupPopoverWithSlottedTriggerAndContentAttrs() { - element = await fixture(html` - - Toggle popover - - `); - } - /** Wait on the element's update cycle */ async function updateComplete() { await element.updateComplete; @@ -50,15 +36,13 @@ describe('', function() { * If the expected children snapshot is undefined, then assistive technology * reports nothing at all, e.g. a popover element with no attrs and no children */ - function expectA11ySnapshot(expected?: Pick[]) { - return async function() { - const snapshot = await a11ySnapshot(); - expect(snapshot.children?.map(takeProps(['name', 'role']))) - .to.deep.equal(expected); - }; + async function expectA11ySnapshot(expected: A11yTreeSnapshot = { role: 'WebArea', name: '' }) { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.deep.equal(expected); } function resetElement() { + document.querySelectorAll('pf-popover').forEach(e => e.remove()); // @ts-expect-error: resetting test state, so we don't mind the ts error. element = undefined; } @@ -71,57 +55,60 @@ describe('', function() { it('should upgrade', async function() { const klass = customElements.get('pf-popover'); expect(element) - .to.be.an.instanceOf(klass) - .and - .to.be.an.instanceOf(PfPopover); + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfPopover); }); it('should be accessible', expectA11yAxe); it('imperatively instantiates', function() { expect(document.createElement('pf-popover')) - .to.be.an.instanceof(PfPopover); + .to.be.an.instanceof(PfPopover); + }); + it('should not report anything to assistive technology', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children).to.not.be.ok; }); - it('should not report anything to assistive technology', expectA11ySnapshot()); }); describe('with a slotted trigger; and with heading, body, and footer attributes', function() { - /** Setup the a11y tree snapshot expected results for this suite */ - const snapshots = { - opened: [ - { - name: 'Toggle popover', - role: 'button', - }, - { - name: 'Close popover', - role: 'button', - }, - { - name: 'Popover heading', - role: 'heading', - }, - { - name: 'Popovers are triggered by click rather than hover.', - role: 'text', - }, - { - name: 'Popover footer', - role: 'text', - }, - ], - closed: [ - { - name: 'Toggle popover', - role: 'button', - } - ], - }; + // these tests are flaky, soo... + beforeEach(resetElement); + beforeEach(nextFrame); + beforeEach(resetElement); + beforeEach(nextFrame); + beforeEach(resetElement); + beforeEach(nextFrame); - beforeEach(setupPopoverWithSlottedTriggerAndContentAttrs); + /** create a test fixture with slotted trigger and content attrs */ + beforeEach(async function setupPopoverWithSlottedTriggerAndContentAttrs() { + element = await fixture(html` + + Toggle popover + + `); + }); it('should be accessible', expectA11yAxe); - it('should hide popover content from assistive technology', expectA11ySnapshot(snapshots.closed)); + + it('should hide popover content from assistive technology', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'dialog')).to.not.be.ok; + }); describe('tabbing to the trigger', function() { + beforeEach(resetElement); + beforeEach(async function setupPopoverWithSlottedTriggerAndContentAttrs() { + element = await fixture(html` + + Toggle popover + + `); + }); + beforeEach(updateComplete); beforeEach(press('Tab')); beforeEach(updateComplete); @@ -134,18 +121,31 @@ describe('', function() { beforeEach(updateComplete); beforeEach(press('Enter')); beforeEach(updateComplete); - it('should show popover content to assistive technology', expectA11ySnapshot(snapshots.opened)); + it('should show popover content to assistive technology', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'dialog')).to.be.ok; + }); describe('then pressing Enter again', function() { beforeEach(updateComplete); beforeEach(press('Enter')); beforeEach(updateComplete); - it('should hide popover content from assistive technology', expectA11ySnapshot(snapshots.closed)); + it('should hide popover content from assistive technology', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot?.children?.length).to.equal(1); + const dialog = snapshot.children?.find(x => x.role === 'dialog'); + expect(dialog).to.not.be.ok; + }); }); describe('then pressing Escape', function() { beforeEach(updateComplete); beforeEach(press('Escape')); beforeEach(updateComplete); - it('should hide popover content from assistive technology', expectA11ySnapshot(snapshots.closed)); + it('should hide popover content from assistive technology', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot?.children?.length).to.equal(1); + const dialog = snapshot.children?.find(x => x.role === 'dialog'); + expect(dialog).to.not.be.ok; + }); }); }); }); @@ -155,46 +155,6 @@ describe('', function() { let btn1: HTMLButtonElement; let btn2: HTMLButtonElement; - /** Setup the a11y tree snapshot expected results for this suite */ - const snapshots = { - opened: [ - { - name: 'Close popover', - role: 'button', - }, - { - name: 'Popover heading', - role: 'heading', - }, - { - name: 'Popovers are triggered by click rather than hover.', - role: 'text', - }, - { - name: 'Popover footer', - role: 'text', - }, - { - name: 'Toggle popover 1', - role: 'button', - }, - { - name: 'Toggle popover 2', - role: 'button', - }, - ], - closed: [ - { - name: 'Toggle popover 1', - role: 'button', - }, - { - name: 'Toggle popover 2', - role: 'button', - }, - ], - }; - async function clickButton1() { await clickElementAtCenter(btn1); await resetMouse(); @@ -222,17 +182,24 @@ describe('', function() { btn2 = container.querySelector('#btn-2')!; }); - it('starts closed', expectA11ySnapshot(snapshots.closed)); + it('starts closed', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'dialog')).to.not.be.ok; + }); + describe('clicking the trigger', function() { beforeEach(updateComplete); beforeEach(clickButton1); beforeEach(updateComplete); - it('shows the popover', expectA11ySnapshot(snapshots.opened)); + it('shows the popover', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'dialog')).to.be.ok; + }); }); - describe('then setting the trigger to the sibling button', function() { + + describe('setting the trigger to the sibling button', function() { beforeEach(updateComplete); - // set trigger attr to the id of the second button - beforeEach(async function() { + beforeEach(function() { element.setAttribute('trigger', 'btn-2'); }); beforeEach(updateComplete); @@ -240,13 +207,26 @@ describe('', function() { beforeEach(updateComplete); beforeEach(clickButton1); beforeEach(updateComplete); - it('remains closed', expectA11ySnapshot(snapshots.closed)); + it('remains closed', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.deep.equal({ + name: '', + role: 'WebArea', + children: [ + { role: 'button', name: 'Toggle popover 1', focused: true }, + { role: 'button', name: 'Toggle popover 2' }, + ], + }); + }); }); describe('clicking the sibling button', function() { beforeEach(updateComplete); beforeEach(clickButton2); beforeEach(updateComplete); - it('shows the popup', expectA11ySnapshot(snapshots.opened)); + it('shows the popover', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'dialog')).to.be.ok; + }); }); }); describe('then pressing the Enter key', function() { @@ -254,7 +234,23 @@ describe('', function() { // Close the popover beforeEach(press('Enter')); beforeEach(updateComplete); - it('closes the popover', expectA11ySnapshot(snapshots.closed)); + it('closes the popover', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot).to.deep.equal({ + role: 'WebArea', + name: '', + children: [ + { + name: 'Toggle popover 1', + role: 'button', + }, + { + name: 'Toggle popover 2', + role: 'button', + }, + ], + }); + }); }); }); }); diff --git a/elements/pf-progress-stepper/demo/alignment.html b/elements/pf-progress-stepper/demo/alignment.html new file mode 100644 index 0000000000..4c30e01fe4 --- /dev/null +++ b/elements/pf-progress-stepper/demo/alignment.html @@ -0,0 +1,38 @@ +
+

With alignment

+ + + + First Step + Second Step + Third Step + +
+ + + + diff --git a/elements/pf-progress-stepper/demo/compact.html b/elements/pf-progress-stepper/demo/compact.html new file mode 100644 index 0000000000..14777bad9c --- /dev/null +++ b/elements/pf-progress-stepper/demo/compact.html @@ -0,0 +1,38 @@ +
+

Compact

+ + + + First Step + Second Step + Third Step + +
+ + + + diff --git a/elements/pf-progress-stepper/demo/custom-icons.html b/elements/pf-progress-stepper/demo/custom-icons.html new file mode 100644 index 0000000000..4801eea447 --- /dev/null +++ b/elements/pf-progress-stepper/demo/custom-icons.html @@ -0,0 +1,18 @@ +
+ + Successful completion + In process + Pending + +
+ + + + diff --git a/elements/pf-progress-stepper/demo/danger.html b/elements/pf-progress-stepper/demo/danger.html new file mode 100644 index 0000000000..a38a7386e2 --- /dev/null +++ b/elements/pf-progress-stepper/demo/danger.html @@ -0,0 +1,20 @@ +
+ + First Step + Second Step + Third Step + Fourth Step + Fifth Step + +
+ + + + diff --git a/elements/pf-progress-stepper/demo/demo.css b/elements/pf-progress-stepper/demo/demo.css deleted file mode 100644 index 36317637db..0000000000 --- a/elements/pf-progress-stepper/demo/demo.css +++ /dev/null @@ -1,10 +0,0 @@ -form, -label pf-switch { - padding-inline: .5rem; -} - -form, -label { - display: block; - padding-block: .5rem; -} diff --git a/elements/pf-progress-stepper/demo/info.html b/elements/pf-progress-stepper/demo/info.html new file mode 100644 index 0000000000..7ed919373b --- /dev/null +++ b/elements/pf-progress-stepper/demo/info.html @@ -0,0 +1,37 @@ +
+ + First Step + Second Step + Third Step + Fourth Step + Fifth Step + +
+ + + + diff --git a/elements/pf-progress-stepper/demo/pf-progress-stepper.html b/elements/pf-progress-stepper/demo/pf-progress-stepper.html index c5eeb00730..4dff746a06 100644 --- a/elements/pf-progress-stepper/demo/pf-progress-stepper.html +++ b/elements/pf-progress-stepper/demo/pf-progress-stepper.html @@ -1,73 +1,18 @@ - - - -
-

Basic

- - First Step - Second Step - Third Step - -
- -
-

With step descriptions

+
- First Step - Second Step - Third Step - -
- -
-

With alignment

- - - - First Step - Second Step - Third Step - -
- -
-

Compact

- - - First Step Second Step Third Step -
- -
-

With an issue

- - First Step - Second Step - Third Step - Fourth Step - Fifth Step -
-
-

With a failure

- - First Step - Second Step - Third Step - Fourth Step - Fifth Step - -
+ -
-

With custom icons

- - Successful completion - In process - Pending - -
+ diff --git a/elements/pf-progress-stepper/demo/pf-progress-stepper.js b/elements/pf-progress-stepper/demo/pf-progress-stepper.js deleted file mode 100644 index c483ac9a6b..0000000000 --- a/elements/pf-progress-stepper/demo/pf-progress-stepper.js +++ /dev/null @@ -1,13 +0,0 @@ -import 'element-internals-polyfill'; -import '@patternfly/elements/pf-switch/pf-switch.js'; -import '@patternfly/elements/pf-progress-stepper/pf-progress-stepper.js'; - -/** @this{HTMLFormElement}*/ -function onChange() { - this.elements.progress.vertical = this.elements.vertical.checked; - this.elements.progress.center = this.elements.center.checked; -} - -for (const form of document.querySelectorAll('form')) { - form.addEventListener('change', onChange); -} diff --git a/elements/pf-progress-stepper/demo/step-descriptions.html b/elements/pf-progress-stepper/demo/step-descriptions.html new file mode 100644 index 0000000000..ff626f626d --- /dev/null +++ b/elements/pf-progress-stepper/demo/step-descriptions.html @@ -0,0 +1,18 @@ +
+ + First Step + Second Step + Third Step + +
+ + + + diff --git a/elements/pf-progress-stepper/pf-progress-step.ts b/elements/pf-progress-stepper/pf-progress-step.ts index 7e8086dac0..d2c97189f6 100644 --- a/elements/pf-progress-stepper/pf-progress-step.ts +++ b/elements/pf-progress-stepper/pf-progress-step.ts @@ -13,9 +13,9 @@ import { InternalsController } from '@patternfly/pfe-core/controllers/internals- import style from './pf-progress-step.css'; const ICONS = new Map(Object.entries({ - success: { icon: 'circle-check' }, - danger: { icon: 'circle-exclamation' }, - warning: { icon: 'triangle-exclamation' }, + success: { icon: 'check-circle' }, + danger: { icon: 'exclamation-circle' }, + warning: { icon: 'exclamation-triangle' }, info: { icon: 'resources-full', set: 'patternfly' }, })); @@ -26,7 +26,6 @@ const ICONS = new Map(Object.entries({ * Longer description of the current step. * @slot icon * Overrides the icon property - * */ @customElement('pf-progress-step') export class PfProgressStep extends LitElement { @@ -51,12 +50,10 @@ export class PfProgressStep extends LitElement { #slots = new SlotController(this, 'title', 'description'); - #internals = new InternalsController(this, { - role: 'listitem', - }); + #internals = InternalsController.of(this, { role: 'listitem' }); render() { - const hasDescription = !!this.description ?? this.#slots.hasSlotted('description'); + const hasDescription = !!(this.description ?? this.#slots.hasSlotted('description')); const icon = this.icon ?? ICONS.get(this.variant ?? 'default')?.icon; const set = this.iconSet ?? ICONS.get(this.variant ?? 'default')?.set; const { parentTagName } = (this.constructor as typeof PfProgressStep); diff --git a/elements/pf-progress-stepper/pf-progress-stepper.ts b/elements/pf-progress-stepper/pf-progress-stepper.ts index 0589003fc0..29331e5c12 100644 --- a/elements/pf-progress-stepper/pf-progress-stepper.ts +++ b/elements/pf-progress-stepper/pf-progress-stepper.ts @@ -29,12 +29,11 @@ export class PfProgressStepper extends LitElement { /** Whether to use the compact layout */ @observed(function(this: PfProgressStepper) { - const { childTagName } = (this.constructor as typeof PfProgressStepper); - this.querySelectorAll(childTagName).forEach(step => step.requestUpdate()); + this.querySelectorAll('pf-progress-step').forEach(step => step.requestUpdate()); }) @property({ type: Boolean, reflect: true }) compact = false; - #internals = new InternalsController(this, { + #internals = InternalsController.of(this, { role: 'progressbar', ariaValueNow: this.value.toString(), }); @@ -59,9 +58,9 @@ export class PfProgressStepper extends LitElement { } render() { - return html` - - `; + // TODO: add label prop + // eslint-disable-next-line lit-a11y/accessible-name + return html`
`; } } diff --git a/elements/pf-progress-stepper/test/pf-progress-stepper.spec.ts b/elements/pf-progress-stepper/test/pf-progress-stepper.spec.ts index 729eb3675c..9fd315145b 100644 --- a/elements/pf-progress-stepper/test/pf-progress-stepper.spec.ts +++ b/elements/pf-progress-stepper/test/pf-progress-stepper.spec.ts @@ -14,8 +14,8 @@ describe('', function() { it('it should upgrade', async function() { const el = await createFixture(html``); expect(el) - .to.be.an.instanceOf(customElements.get('pf-progress-stepper')) - .and - .to.be.an.instanceOf(PfProgressStepper); + .to.be.an.instanceOf(customElements.get('pf-progress-stepper')) + .and + .to.be.an.instanceOf(PfProgressStepper); }); }); diff --git a/elements/pf-progress/demo/demo.css b/elements/pf-progress/demo/demo.css deleted file mode 100644 index 4ef939b648..0000000000 --- a/elements/pf-progress/demo/demo.css +++ /dev/null @@ -1,6 +0,0 @@ -[data-demo="pf-progress"] { - max-width: 600px; -} -pf-progress { - width: 100%; -} diff --git a/elements/pf-progress/demo/kitchen-sink.html b/elements/pf-progress/demo/kitchen-sink.html index d5f19ced86..36c0dd7d80 100644 --- a/elements/pf-progress/demo/kitchen-sink.html +++ b/elements/pf-progress/demo/kitchen-sink.html @@ -136,3 +136,6 @@

Truncated description

description="Very very very very very very very very very very very long description which should be truncated if it does not fit onto one line above the progress bar" > + diff --git a/elements/pf-progress/demo/pf-progress.html b/elements/pf-progress/demo/pf-progress.html index adfee3bcf2..76f32e9ec3 100644 --- a/elements/pf-progress/demo/pf-progress.html +++ b/elements/pf-progress/demo/pf-progress.html @@ -1,5 +1,5 @@ - + -

Default:

- - \ No newline at end of file + diff --git a/elements/pf-progress/demo/pf-progress.js b/elements/pf-progress/demo/pf-progress.js deleted file mode 100644 index bc46f8f80b..0000000000 --- a/elements/pf-progress/demo/pf-progress.js +++ /dev/null @@ -1 +0,0 @@ -import '@patternfly/elements/pf-progress/pf-progress.js'; diff --git a/elements/pf-progress/demo/truncated-description.html b/elements/pf-progress/demo/truncated-description.html index 62993a68c4..72d7e9f0f3 100644 --- a/elements/pf-progress/demo/truncated-description.html +++ b/elements/pf-progress/demo/truncated-description.html @@ -1,10 +1,20 @@ - +
+ +
- + + diff --git a/elements/pf-progress/pf-progress.ts b/elements/pf-progress/pf-progress.ts index a8dc28e021..93094bd5fb 100644 --- a/elements/pf-progress/pf-progress.ts +++ b/elements/pf-progress/pf-progress.ts @@ -11,90 +11,69 @@ import styles from './pf-progress.css'; const ICONS = new Map(Object.entries({ success: { icon: 'circle-check' }, danger: { icon: 'circle-xmark' }, - warning: { icon: 'triangle-exclamation' } + warning: { icon: 'triangle-exclamation' }, })); /** * A progress bar gives the user a visual representation of their completion status of an ongoing process or task. - * * @summary Display completion status of ongoing process or task. - * * @cssprop {} --pf-c-progress--GridGap * Gap between the sections of the progress bar. * {@default `1rem`} - * * @cssprop {} --pf-c-progress__bar--before--BackgroundColor * Color of the progress bar. * {@default `#06c`} - * * @cssprop {} --pf-c-progress__bar--Height * Height of the progress bar. * {@default `1rem`} - * * @cssprop {} --pf-c-progress__bar--BackgroundColor * Background color of the progress bar. * {@default `#ffffff`} - * * @cssprop {} --pf-c-progress__status-icon--Color * Color of the status icon. * {@default `#151515`} - * * @cssprop {} --pf-c-progress__status-icon--MarginLeft * Margin left of the status icon. * {@default `0.5rem`} - * * @cssprop {} --pf-c-progress__indicator--Height * Height of the progress bar indicator. * {@default `1rem`} - * * @cssprop {} --pf-c-progress__indicator--BackgroundColor * Background color of the progress bar indicator. * {@default `#ffffff`} - * * @cssprop {} --pf-c-progress--m-success__bar--BackgroundColor * Background color of the progress bar when variant is success. * {@default `#3e8635`} - * * @cssprop {} --pf-c-progress--m-warning__bar--BackgroundColor * Background color of the progress bar when variant is warning. * {@default `#f0ab00`} - * * @cssprop {} --pf-c-progress--m-danger__bar--BackgroundColor * Background color of the progress bar when variant is danger. * {@default `#c9190b`} - * * @cssprop {} --pf-c-progress--m-success__status-icon--Color * Color of the status icon when variant is success. * {@default `#3e8635`} - * * @cssprop {} --pf-c-progress--m-warning__status-icon--Color * Color of the status icon when variant is warning. * {@default `#f0ab00`} - * * @cssprop {} --pf-c-progress--m-danger__status-icon--Color * Color of the status icon when variant is danger. * {@default `#c9190b`} - * * @cssprop {} --pf-c-progress--m-success--m-inside__measure--Color * Color of the progress bar measure when variant is success and measure location is inside. * {@default `#ffffff`} - * * @cssprop {} --pf-c-progress--m-outside__measure--FontSize * Font size of the progress bar measure when measure location is outside. * {@default `0.875rem`} - * * @cssprop {} --pf-c-progress--m-sm__bar--Height * Height of the progress bar when the size is small. * {@default `0.5rem`} - * * @cssprop {} --pf-c-progress--m-sm__description--FontSize * Font size of the progress bar description when the size is small. * {@default `0.875rem`} - * * @cssprop {} --pf-c-progress--m-lg__bar--Height * Height of the progress bar when the size is large. * {@default `1.5rem`} - * */ @customElement('pf-progress') export class PfProgress extends LitElement { diff --git a/elements/pf-select/README.md b/elements/pf-select/README.md new file mode 100644 index 0000000000..1451de32b0 --- /dev/null +++ b/elements/pf-select/README.md @@ -0,0 +1,22 @@ +# Select + +A select list enables users to select one or more items from a list. + +## Usage + +A select component consists of a toggle control to open and close a menu of actions or links. +Selects differ from dropdowns in that they persist selection, whereas dropdowns are typically used to present a list of actions or links. + +```html + + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + +``` diff --git a/elements/pf-select/demo/checkbox-input-no-badge.html b/elements/pf-select/demo/checkbox-input-no-badge.html new file mode 100644 index 0000000000..b40dd536db --- /dev/null +++ b/elements/pf-select/demo/checkbox-input-no-badge.html @@ -0,0 +1,13 @@ + + Debug + Info + Warn + Error + + + diff --git a/elements/pf-select/demo/checkbox-input.html b/elements/pf-select/demo/checkbox-input.html new file mode 100644 index 0000000000..962bede1f6 --- /dev/null +++ b/elements/pf-select/demo/checkbox-input.html @@ -0,0 +1,14 @@ + + Active + Cancelled + Paused +
+ Warning + Restarted +
+ + diff --git a/elements/pf-select/demo/grouped-checkbox-input.html b/elements/pf-select/demo/grouped-checkbox-input.html new file mode 100644 index 0000000000..535c746a95 --- /dev/null +++ b/elements/pf-select/demo/grouped-checkbox-input.html @@ -0,0 +1,19 @@ + + + Running + Stopped + Down + Degraded + Needs maintenance + +
+ + Dell + Samsung + Hewlett-Packard + +
+ + diff --git a/elements/pf-select/demo/grouped-single.html b/elements/pf-select/demo/grouped-single.html new file mode 100644 index 0000000000..d929374fc8 --- /dev/null +++ b/elements/pf-select/demo/grouped-single.html @@ -0,0 +1,19 @@ + + + Running + Stopped + Down + Degraded + Needs maintenance + +
+ + Dell + Samsung + Hewlett-Packard + +
+ + diff --git a/elements/pf-select/demo/pf-select.html b/elements/pf-select/demo/pf-select.html new file mode 100644 index 0000000000..de62d6e2de --- /dev/null +++ b/elements/pf-select/demo/pf-select.html @@ -0,0 +1,14 @@ + + Mr + Miss + Mrs + Ms +
+ Dr + Other +
+ + diff --git a/elements/pf-select/demo/single-with-description.html b/elements/pf-select/demo/single-with-description.html new file mode 100644 index 0000000000..a939a5e328 --- /dev/null +++ b/elements/pf-select/demo/single-with-description.html @@ -0,0 +1,14 @@ + + Mr + Miss + Mrs + Ms + Dr + Descriptions can also be HTML + + Other + + + diff --git a/elements/pf-select/demos-to-implement-later/multiple.html b/elements/pf-select/demos-to-implement-later/multiple.html new file mode 100644 index 0000000000..72de91f3f9 --- /dev/null +++ b/elements/pf-select/demos-to-implement-later/multiple.html @@ -0,0 +1,19 @@ + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + +

+ Shift will toggling off multiple items. + Ctrl+A will toggle selection on all items. +

+ + diff --git a/elements/pf-select/demos-to-implement-later/typeahead-chips.html b/elements/pf-select/demos-to-implement-later/typeahead-chips.html new file mode 100644 index 0000000000..2ba13235ad --- /dev/null +++ b/elements/pf-select/demos-to-implement-later/typeahead-chips.html @@ -0,0 +1,17 @@ + + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + + diff --git a/elements/pf-select/demos-to-implement-later/typeahead-create-option.html b/elements/pf-select/demos-to-implement-later/typeahead-create-option.html new file mode 100644 index 0000000000..5c676d44a4 --- /dev/null +++ b/elements/pf-select/demos-to-implement-later/typeahead-create-option.html @@ -0,0 +1,17 @@ + + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + + diff --git a/elements/pf-select/demos-to-implement-later/typeahead-custom-filter.html b/elements/pf-select/demos-to-implement-later/typeahead-custom-filter.html new file mode 100644 index 0000000000..e4393b6a8a --- /dev/null +++ b/elements/pf-select/demos-to-implement-later/typeahead-custom-filter.html @@ -0,0 +1,18 @@ + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + + diff --git a/elements/pf-select/demos-to-implement-later/typeahead-disable-filter.html b/elements/pf-select/demos-to-implement-later/typeahead-disable-filter.html new file mode 100644 index 0000000000..66683f85f5 --- /dev/null +++ b/elements/pf-select/demos-to-implement-later/typeahead-disable-filter.html @@ -0,0 +1,15 @@ + + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + + + diff --git a/elements/pf-select/demos-to-implement-later/typeahead-multiple.html b/elements/pf-select/demos-to-implement-later/typeahead-multiple.html new file mode 100644 index 0000000000..45fe5d6d08 --- /dev/null +++ b/elements/pf-select/demos-to-implement-later/typeahead-multiple.html @@ -0,0 +1,15 @@ + + + diff --git a/elements/pf-select/demos-to-implement-later/typeahead.html b/elements/pf-select/demos-to-implement-later/typeahead.html new file mode 100644 index 0000000000..e6e50e1477 --- /dev/null +++ b/elements/pf-select/demos-to-implement-later/typeahead.html @@ -0,0 +1,15 @@ + + + diff --git a/elements/pf-select/docs/pf-select.md b/elements/pf-select/docs/pf-select.md new file mode 100644 index 0000000000..e0de802f5d --- /dev/null +++ b/elements/pf-select/docs/pf-select.md @@ -0,0 +1,164 @@ +{% renderInstallation %} {% endrenderInstallation %} + + + +{% renderOverview %} + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + +{% endrenderOverview %} + +{% band header="Usage" %} + +#### Single + +Focus on options using arrow keys or +by typing the first character of an option. + +{% htmlexample %} + {% renderFile "./elements/pf-select/demo/pf-select.html" %} +{% endhtmlexample %} + +#### Single with description +{% htmlexample %} + {% renderFile "./elements/pf-select/demo/single-with-description.html" %} +{% endhtmlexample %} + +#### Grouped single +{% htmlexample %} + {% renderFile "./elements/pf-select/demo/grouped-single.html" %} +{% endhtmlexample %} + +#### Checkbox input + +Multiple options can be selected. Any arrow keys work. +Shift will toggling off multiple items. +Ctrl+A will toggle selection on all items. + +{% htmlexample %} + {% renderFile "./elements/pf-select/demo/checkbox-input.html" %} +{% endhtmlexample %} + +{# save this for v5 +### Option variations + +Below are option variants: + +{% htmlexample %} + + Basic option + + Option with description + This is a description + + + + Option with icon + + Aria-disabled option + +{% endhtmlexample %} +#} +When setting the `disabled` attribute on options, they are still focusable, but +they are not selectable. This is in order that they remain accessible to screen +readers. This functions similarly to the `aria-disabled="true"` attribute. + +{% renderFile "./docs/_snippets/wai-aria-disabled.md" %} + +{# + ### Typeahead + + {% htmlexample %} + {% renderFile "./elements/pf-select/demo/typeahead.html" %} + {% endhtmlexample %} + + #### Multiple + + {% htmlexample %} + {% renderFile "./elements/pf-select/demo/typeahead-multiple.html" %} + {% endhtmlexample %} + + #### Custom filtering + + By default, filtering is **enabled** and **not** case sensitive. + However, filtering can be customized with the `customFilter` option, + which is a predicate function that takes an option. + + {% htmlexample %} + {% renderFile "./elements/pf-select/demo/typeahead-custom-filter.html" %} + {% endhtmlexample %} +#} + +{% endband %} + +{% band header="Accessibility" %} + +The select uses the [Combobox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) recommendations from the WAI ARIA [Authoring Best Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg). + +When the dropdown is disabled it follows [WAI ARIA focusability recommendations](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols) for composite widget elements, where dropdown items are still focusable even when the dropdown is disabled. + +#### Toggle button and typeahead input + +When focus is on the toggle button, the following keyboard interactions apply: + +| Key | Function | +| ---------------------- | -------------------------------------------------------------------------------------- | +| Enter | Opens the listbox. | +| Space | Opens the listbox. | +| Down Arrow | Opens the listbox and moves focus to the first listbox item. | +| Tab | Moves focus out of select element onto the next focusable item and closes listbox. | +| Shift + Tab | Moves focus out of select element onto the previous focusable item and closes listbox. | + +#### Listbox options + +Listbox options use the [APG's Roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) recommendation. When focus is on the listbox, the following keyboard interactions apply: + +| Key | Function | +| ---------------------- | ------------------------------------------------------------------------------------- | +| Enter | Selects the options and closes the listbox. | +| Space | Selects the options and closes the listbox. | +| Shift | Enables multiselect. | +| Control + A | Selects all options. | +| Tab | Moves focus out of select element onto the next focusable options and closes listbox. | +| Shift + Tab | Moves focus to the toggle button and closes listbox. | +| Up Arrow | Moves focus to the previous option, optionally wrapping from the first to the last. | +| Down Arrow | Moves focus to the next option, optionally wrapping from the last to the first. | +| Left Arrow | Moves focus to the previous option, optionally wrapping from the first to the last. | +| Right Arrow | Moves focus to the next option, optionally wrapping from the last to the first. | +| Home | Moves focus to the first option in the current listbox. | +| End | Moves focus to the last option in the current listbox. | +| Escape | Close the listbox that contains focus and return focus to the toggle button. | +| Any letter | Navigates to the next option that starts with the letter. | + +{% endband %} + +{% renderSlots for="pf-select", header="Slots on `pf-select`" %}{% endrenderSlots %} +{% renderAttributes for="pf-select", header="Attributes on `pf-select`" %}{% endrenderAttributes %} +{% renderMethods for="pf-select", header="Methods on `pf-select`" %}{% endrenderMethods %} +{% renderEvents for="pf-select", header="Events on `pf-select`" %}{% endrenderEvents %} +{% renderCssCustomProperties for="pf-select", header="CSS Custom Properties on `pf-select`" %}{% endrenderCssCustomProperties %} +{% renderCssParts for="pf-select", header="CSS Parts on `pf-select`" %}{% endrenderCssParts %} + +{% renderSlots for="pf-option-group", header="Slots on `pf-option-group`" %}{% endrenderSlots %} +{% renderAttributes for="pf-option-group", header="Attributes on `pf-option-group`" %}{% endrenderAttributes %} +{% renderMethods for="pf-option-group", header="Methods on `pf-option-group`" %}{% endrenderMethods %} +{% renderEvents for="pf-option-group", header="Events on `pf-option-group`" %}{% endrenderEvents %} +{% renderCssCustomProperties for="pf-option-group", header="CSS Custom Properties on `pf-option-group`" %}{% endrenderCssCustomProperties %} +{% renderCssParts for="pf-option-group", header="CSS Parts on `pf-option-group`" %}{% endrenderCssParts %} + +{% renderSlots for="pf-option", header="Slots on `pf-option`" %}{% endrenderSlots %} +{% renderAttributes for="pf-option", header="Attributes on `pf-option`" %}{% endrenderAttributes %} +{% renderMethods for="pf-option", header="Methods on `pf-option`" %}{% endrenderMethods %} +{% renderEvents for="pf-option", header="Events on `pf-option`" %}{% endrenderEvents %} +{% renderCssCustomProperties for="pf-option", header="CSS Custom Properties on `pf-option`" %}{% endrenderCssCustomProperties %} +{% renderCssParts for="pf-option", header="CSS Parts on `pf-option`" %}{% endrenderCssParts %} diff --git a/elements/pf-select/docs/screenshot.png b/elements/pf-select/docs/screenshot.png new file mode 100644 index 0000000000..f034f03f14 Binary files /dev/null and b/elements/pf-select/docs/screenshot.png differ diff --git a/elements/pf-select/pf-option-group.css b/elements/pf-select/pf-option-group.css new file mode 100644 index 0000000000..cf4b47c5ef --- /dev/null +++ b/elements/pf-select/pf-option-group.css @@ -0,0 +1,25 @@ +:host { + display: block; + border-bottom: 1px solid var(--pf-global--BorderColor--100, #d2d2d2); +} + +:host([disabled]) { + pointer-events: none; + cursor: not-allowed; + color: var(--pf-global--Color--200, #6a6e73) !important; + background-color: var(--pf-theme--color--surface--lighter, #f0f0f0) !important; + border-color: var(--pf-theme--color--surface--lighter, #f0f0f0) !important; + --_active-descendant-color: var(--pf-theme--color--surface--lighter, #f0f0f0) !important; + --_svg-color: var(--pf-global--Color--200, #6a6e73) !important; +} + +slot { + display: block; + padding: var(--pf-global--spacer--md, 1rem) 0; +} + +slot[name="label"] { + font-size: var(--pf-global--FontSize--xs, 0.75rem); + color: var(--pf-global--Color--dark-200, #6a6e73); + padding: var(--pf-global--spacer--md, 1rem) var(--pf-global--spacer--md, 1rem) 0; +} diff --git a/elements/pf-select/pf-option-group.ts b/elements/pf-select/pf-option-group.ts new file mode 100644 index 0000000000..c626bdf298 --- /dev/null +++ b/elements/pf-select/pf-option-group.ts @@ -0,0 +1,46 @@ +import { LitElement, html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; + +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; + +import styles from './pf-option-group.css'; + +/** + * Group of options within a listbox + * @slot - `` or `
` elements + * @slot label - Group label. Overrides the `label` attribute. + */ +@customElement('pf-option-group') +export class PfOptionGroup extends LitElement { + static readonly styles = [styles]; + + /** Group description. Overridden by `label` slot. */ + @property() label?: string; + + /** whether group is disabled */ + @property({ type: Boolean, reflect: true }) disabled = false; + + // for the role + // eslint-disable-next-line no-unused-private-class-members + #internals = InternalsController.of(this, { role: 'group' }); + + render() { + const { disabled } = this; + return html` + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-option-group': PfOptionGroup; + } +} diff --git a/elements/pf-select/pf-option.css b/elements/pf-select/pf-option.css new file mode 100644 index 0000000000..1b6172987b --- /dev/null +++ b/elements/pf-select/pf-option.css @@ -0,0 +1,74 @@ +:host { + display: block; +} + +:host([hidden]), +*[hidden] { + display: none !important; +} + +:host([disabled]) { + pointer-events: none !important; + cursor: not-allowed !important; +} + +:host(:focus) #outer, +:host(:hover) #outer, +:host([aria-selected="true"]) { + background-color: #e0e0e0; +} + +#outer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + padding: var(--pf-global--spacer--sm, 0.5rem) var(--pf-global--spacer--md, 1rem); + min-height: calc(44px - 2 * var(--pf-global--spacer--sm, 0.5rem)); + min-width: calc(44px - 2 * var(--pf-global--spacer--md, 1rem)); +} + +#outer.active { + background-color: var(--_active-descendant-color, var(--pf-theme--color--surface--lighter, #f0f0f0)); +} + +:host([disabled]) #outer { + color: var(--pf-global--Color--dark-200, #6a6e73) !important; +} + +input[type="checkbox"] { + margin-inline-end: 1em; + display: var(--_pf-option-checkboxes-display, none); + pointer-events: none; + flex: 0 0 auto; +} + +span { + flex: 1 1 auto; +} + +svg { + font-size: var(--pf-c-select__menu-item-icon--FontSize, var(--pf-global--icon--FontSize--sm, 0.675rem)); + color: var(--_svg-color, var(--pf-theme--color--accent, #0066cc)); + width: 1em; + height: 1em; + margin-inline-start: 1em; + text-align: right; + flex: 0 0 auto; + display: var(--_pf-option-svg-display, block); +} + +#description { + display: block; + flex: 1 0 100%; +} + +slot[name="description"] { + font-size: var(--pf-global--FontSize--xs, 0.75rem); + color: var(--pf-global--Color--dark-200, #6a6e73); +} + +::slotted([slot="icon"]) { + margin-inline-end: 0.5em; +} + diff --git a/elements/pf-select/pf-option.ts b/elements/pf-select/pf-option.ts new file mode 100644 index 0000000000..d616d24797 --- /dev/null +++ b/elements/pf-select/pf-option.ts @@ -0,0 +1,140 @@ +import { LitElement, html, type PropertyValues } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { queryAssignedNodes } from 'lit/decorators/query-assigned-nodes.js'; +import { property } from 'lit/decorators/property.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; + +import styles from './pf-option.css'; + +/** + * Option within a listbox + * @slot - + * option text + * @slot icon + * optional icon + * @slot description + * optional description + */ +@customElement('pf-option') +export class PfOption extends LitElement { + static readonly styles = [styles]; + + /** whether option is disabled */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** form value for this option */ + @property({ reflect: true }) + get value() { + return this.#value ?? this.textContent ?? ''; + } + + set value(v: string) { + this.#value = v; + } + + /** whether option is selected */ + @property({ type: Boolean }) selected = false; + + /** whether option is active descendant */ + @property({ type: Boolean }) active = false; + + /** Optional option description; overridden by description slot. */ + @property() description = ''; + + @queryAssignedNodes({ slot: '', flatten: true }) + private _slottedText!: Node[]; + + /** + * this option's position relative to the other options + */ + set posInSet(posInSet: number | null) { + this.#internals.ariaPosInSet = `${Math.max(0, posInSet ?? 0)}`; + } + + get posInSet() { + const parsed = parseInt(this.#internals.ariaPosInSet ?? '0'); + return Number.isNaN(parsed) ? null : parsed; + } + + /** + * total number of options + */ + set setSize(setSize: number | null) { + this.#internals.ariaSetSize = `${Math.max(0, setSize ?? 0)}`; + } + + get setSize() { + try { + const int = parseInt(this.#internals.ariaSetSize ?? '0'); + if (Number.isNaN(int)) { + return 0; + } else { + return int; + } + } catch { + return 0; + } + } + + #value?: string; + + #internals = InternalsController.of(this, { role: 'option' }); + + override connectedCallback() { + super.connectedCallback(); + this.id ||= getRandomId(); + } + + render() { + const { disabled, active } = this; + return html` +
+ + + + + + + + ${this.description ?? ''} +
+ `; + } + + willUpdate(changed: PropertyValues) { + if (changed.has('selected') + // don't fire on initialization + && !(changed.get('selected') === undefined) && this.selected === false) { + this.#internals.ariaSelected = this.selected ? 'true' : 'false'; + } + if (changed.has('disabled')) { + this.#internals.ariaDisabled = String(!!this.disabled); + } + } + + /** + * text content within option (used for filtering) + */ + get optionText() { + return this._slottedText.map(node => node.textContent).join('').trim(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-option': PfOption; + } +} diff --git a/elements/pf-select/pf-select.css b/elements/pf-select/pf-select.css new file mode 100644 index 0000000000..2f462bc676 --- /dev/null +++ b/elements/pf-select/pf-select.css @@ -0,0 +1,371 @@ +:host { + font-family: var(--pf-global--FontFamily--sans-serif, "RedHatTextUpdated", "Overpass", overpass, helvetica, arial, sans-serif); + font-size: var(--pf-global--FontSize--md, 16px); + font-weight: var(--pf-global--FontWeight--normal, 400); + color: var(--pf-global--Color--100, #151515); + --_pf-option-checkboxes-display: none; + --_pf-option-svg-display: block; + --pf-c-select__toggle--PaddingTop: var(--pf-global--spacer--form-element, 0.375rem); + --pf-c-select__toggle--PaddingRight: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__toggle--PaddingBottom: var(--pf-global--spacer--form-element, 0.375rem); + --pf-c-select__toggle--PaddingLeft: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__toggle--MinWidth: var(--pf-global--target-size--MinWidth, 44px); + --pf-c-select__toggle--FontSize: var(--pf-global--FontSize--md, 1rem); + --pf-c-select__toggle--FontWeight: var(--pf-global--FontWeight--normal, 400); + --pf-c-select__toggle--LineHeight: var(--pf-global--LineHeight--md, 1.5); + --pf-c-select__toggle--BackgroundColor: var(--pf-global--BackgroundColor--100, #fff); + --pf-c-select__toggle--before--BorderTopWidth: var(--pf-global--BorderWidth--sm, 1px); + --pf-c-select__toggle--before--BorderRightWidth: var(--pf-global--BorderWidth--sm, 1px); + --pf-c-select__toggle--before--BorderBottomWidth: var(--pf-global--BorderWidth--sm, 1px); + --pf-c-select__toggle--before--BorderLeftWidth: var(--pf-global--BorderWidth--sm, 1px); + --pf-c-select__toggle--before--BorderWidth: initial; + --pf-c-select__toggle--before--BorderTopColor: var(--pf-global--BorderColor--300, #f0f0f0); + --pf-c-select__toggle--before--BorderRightColor: var(--pf-global--BorderColor--300, #f0f0f0); + --pf-c-select__toggle--before--BorderBottomColor: var(--pf-global--BorderColor--200, #8a8d90); + --pf-c-select__toggle--before--BorderLeftColor: var(--pf-global--BorderColor--300, #f0f0f0); + --pf-c-select__toggle--Color: var(--pf-global--Color--100, #151515); + --pf-c-select__toggle--hover--before--BorderBottomColor: var(--pf-global--active-color--100, #06c); + --pf-c-select__toggle--focus--before--BorderBottomColor: var(--pf-global--active-color--100, #06c); + --pf-c-select__toggle--focus--before--BorderBottomWidth: var(--pf-global--BorderWidth--md, 2px); + --pf-c-select__toggle--active--before--BorderBottomColor: var(--pf-global--active-color--100, #06c); + --pf-c-select__toggle--active--before--BorderBottomWidth: var(--pf-global--BorderWidth--md, 2px); + --pf-c-select__toggle--m-expanded--before--BorderBottomColor: var(--pf-global--active-color--100, #06c); + --pf-c-select__toggle--m-expanded--before--BorderBottomWidth: var(--pf-global--BorderWidth--md, 2px); + --pf-c-select__toggle--disabled--BackgroundColor: var(--pf-global--disabled-color--300, #f0f0f0); + --pf-c-select__toggle--m-plain--before--BorderColor: transparent; + --pf-c-select__toggle--m-placeholder--Color: transparent; + --pf-c-select--m-invalid__toggle--before--BorderBottomColor: var(--pf-global--danger-color--100, #c9190b); + --pf-c-select--m-invalid__toggle--before--BorderBottomWidth: var(--pf-global--BorderWidth--md, 2px); + --pf-c-select--m-invalid__toggle--hover--before--BorderBottomColor: var(--pf-global--danger-color--100, #c9190b); + --pf-c-select--m-invalid__toggle--focus--before--BorderBottomColor: var(--pf-global--danger-color--100, #c9190b); + --pf-c-select--m-invalid__toggle--active--before--BorderBottomColor: var(--pf-global--danger-color--100, #c9190b); + --pf-c-select--m-invalid__toggle--m-expanded--before--BorderBottomColor: var(--pf-global--danger-color--100, #c9190b); + --pf-c-select--m-invalid__toggle-status-icon--Color: var(--pf-global--danger-color--100, #c9190b); + --pf-c-select--m-success__toggle--before--BorderBottomColor: var(--pf-global--success-color--100, #3e8635); + --pf-c-select--m-success__toggle--before--BorderBottomWidth: var(--pf-global--BorderWidth--md, 2px); + --pf-c-select--m-success__toggle--hover--before--BorderBottomColor: var(--pf-global--success-color--100, #3e8635); + --pf-c-select--m-success__toggle--focus--before--BorderBottomColor: var(--pf-global--success-color--100, #3e8635); + --pf-c-select--m-success__toggle--active--before--BorderBottomColor: var(--pf-global--success-color--100, #3e8635); + --pf-c-select--m-success__toggle--m-expanded--before--BorderBottomColor: var(--pf-global--success-color--100, #3e8635); + --pf-c-select--m-success__toggle-status-icon--Color: var(--pf-global--success-color--100, #3e8635); + --pf-c-select--m-warning__toggle--before--BorderBottomColor: var(--pf-global--warning-color--100, #f0ab00); + --pf-c-select--m-warning__toggle--before--BorderBottomWidth: var(--pf-global--BorderWidth--md, 2px); + --pf-c-select--m-warning__toggle--hover--before--BorderBottomColor: var(--pf-global--warning-color--100, #f0ab00); + --pf-c-select--m-warning__toggle--focus--before--BorderBottomColor: var(--pf-global--warning-color--100, #f0ab00); + --pf-c-select--m-warning__toggle--active--before--BorderBottomColor: var(--pf-global--warning-color--100, #f0ab00); + --pf-c-select--m-warning__toggle--m-expanded--before--BorderBottomColor: var(--pf-global--warning-color--100, #f0ab00); + --pf-c-select--m-warning__toggle-status-icon--Color: var(--pf-global--warning-color--100, #f0ab00); + --pf-c-select__toggle-wrapper--not-last-child--MarginRight: var(--pf-global--spacer--xs, 0.25rem); + --pf-c-select__toggle-wrapper--MaxWidth: calc(100% - var(--pf-global--spacer--lg, 1.5rem)); + --pf-c-select__toggle-wrapper--c-chip-group--MarginTop: 0.3125rem; + --pf-c-select__toggle-wrapper--c-chip-group--MarginBottom: 0.3125rem; + --pf-c-select__toggle-typeahead--FlexBasis: 10em; + --pf-c-select__toggle-typeahead--BackgroundColor: transparent; + --pf-c-select__toggle-typeahead--BorderTop: var(--pf-global--BorderWidth--sm, 1px) solid transparent; + --pf-c-select__toggle-typeahead--BorderRight: none; + --pf-c-select__toggle-typeahead--BorderLeft: none; + --pf-c-select__toggle-typeahead--MinWidth: 7.5rem; + --pf-c-select__toggle-typeahead--focus--PaddingBottom: calc(var(--pf-global--spacer--form-element, 0.375rem) - var(--pf-global--BorderWidth--md)); + --pf-c-select__toggle--m-placeholder__toggle-text--Color: var(--pf-global--Color--dark-200, #6a6e73); + --pf-c-select__toggle-icon--toggle-text--MarginLeft: var(--pf-global--spacer--xs, 0.25rem); + --pf-c-select__toggle-badge--PaddingLeft: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__toggle-status-icon--MarginLeft: var(--pf-global--spacer--xs, 0.25rem); + --pf-c-select__toggle-status-icon--Color: var(--pf-global--Color--100, #151515); + --pf-c-select__toggle-arrow--MarginLeft: var(--pf-global--spacer--md, 1rem); + --pf-c-select__toggle-arrow--MarginRight: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__toggle-arrow--with-clear--MarginLeft: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__toggle-arrow--m-top--m-expanded__toggle-arrow--Rotate: 180deg; + --pf-c-select--m-plain__toggle-arrow--Color: var(--pf-global--Color--200, #6a6e73); + --pf-c-select--m-plain--hover__toggle-arrow--Color: var(--pf-global--Color--100, #151515); + --pf-c-select__toggle-clear--PaddingRight: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__toggle-clear--PaddingLeft: var(--pf-global--spacer--md, 1rem); + --pf-c-select__toggle-clear--toggle-button--PaddingLeft: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__toggle-button--Color: var(--pf-global--Color--100, #151515); + --pf-c-select__menu--BackgroundColor: var(--pf-global--BackgroundColor--light-100, #fff); + --pf-c-select__menu--BoxShadow: var(--pf-global--BoxShadow--md, 0 0.25rem 0.5rem 0rem rgba(3, 3, 3, 0.12), 0 0 0.25rem 0 rgba(3, 3, 3, 0.06)); + --pf-c-select__menu--PaddingTop: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__menu--PaddingBottom: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__menu--Top: calc(100% + var(--pf-global--spacer--xs, 0.25rem)); + --pf-c-select__menu--ZIndex: var(--pf-global--ZIndex--sm, 200); + --pf-c-select__menu--Width: auto; + --pf-c-select__menu--MinWidth: 100%; + --pf-c-select__menu--m-top--TranslateY: calc(-100% - var(--pf-global--spacer--xs, 0.25rem)); + --pf-c-select__list-item--m-loading--PaddingTop: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__menu-item--PaddingTop: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__menu-item--PaddingRight: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-item--m-selected--PaddingRight: var(--pf-global--spacer--2xl, 3rem); + --pf-c-select__menu-item--PaddingBottom: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__menu-item--PaddingLeft: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-item--FontSize: var(--pf-global--FontSize--md, 1rem); + --pf-c-select__menu-item--FontWeight: var(--pf-global--FontWeight--normal, 400); + --pf-c-select__menu-item--LineHeight: var(--pf-global--LineHeight--md, 1.5); + --pf-c-select__menu-item--Color: var(--pf-global--Color--dark-100, #151515); + --pf-c-select__menu-item--disabled--Color: var(--pf-global--Color--dark-200, #6a6e73); + --pf-c-select__menu-item--Width: 100%; + --pf-c-select__menu-item--hover--BackgroundColor: var(--pf-global--BackgroundColor--light-300, #f0f0f0); + --pf-c-select__menu-item--focus--BackgroundColor: var(--pf-global--BackgroundColor--light-300, #f0f0f0); + --pf-c-select__menu-item--disabled--BackgroundColor: transparent; + --pf-c-select__menu-item--m-link--Width: auto; + --pf-c-select__menu-item--m-link--hover--BackgroundColor: transparent; + --pf-c-select__menu-item--m-link--focus--BackgroundColor: transparent; + --pf-c-select__menu-item--m-action--Color: var(--pf-global--Color--200, #6a6e73); + --pf-c-select__menu-item--m-action--hover--Color: var(--pf-global--Color--100, #151515); + --pf-c-select__menu-item--m-action--focus--Color: var(--pf-global--Color--100, #151515); + --pf-c-select__menu-item--m-action--disabled--Color: var(--pf-global--disabled-color--200, #d2d2d2); + --pf-c-select__menu-item--m-action--Width: auto; + --pf-c-select__menu-item--m-action--FontSize: var(--pf-global--icon--FontSize--sm, 0.625rem); + --pf-c-select__menu-item--m-action--hover--BackgroundColor: transparent; + --pf-c-select__menu-item--m-action--focus--BackgroundColor: transparent; + --pf-c-select__menu-item--hover__menu-item--m-action--Color: var(--pf-global--Color--200, #6a6e73); + --pf-c-select__menu-item--m-favorite-action--Color: var(--pf-global--Color--200, #6a6e73); + --pf-c-select__menu-item--m-favorite-action--hover--Color: var(--pf-global--Color--100, #151515); + --pf-c-select__menu-item--m-favorite-action--focus--Color: var(--pf-global--Color--100, #151515); + --pf-c-select__menu-wrapper--m-favorite__menu-item--m-favorite-action--Color: var(--pf-global--palette--gold-400, #f0ab00); + --pf-c-select__menu-wrapper--m-favorite__menu-item--m-favorite-action--hover--Color: var(--pf-global--palette--gold-500, #c58c00); + --pf-c-select__menu-wrapper--m-favorite__menu-item--m-favorite-action--focus--Color: var(--pf-global--palette--gold-500, #c58c00); + --pf-c-select__menu-item--m-load--Color: var(--pf-global--link--Color, #06c); + --pf-c-select__menu-item-icon--Color: var(--pf-global--active-color--100, #06c); + --pf-c-select__menu-item-icon--FontSize: var(--pf-global--icon--FontSize--sm, 0.625rem); + --pf-c-select__menu-item-icon--Right: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-item-icon--Top: 50%; + --pf-c-select__menu-item-icon--TranslateY: -50%; + --pf-c-select__menu-item-action-icon--MinHeight: calc(var(--pf-c-select__menu-item--FontSize) * var(--pf-c-select__menu-item--LineHeight)); + --pf-c-select__menu-item--match--FontWeight: var(--pf-global--FontWeight--bold, 700); + --pf-c-select__menu-search--PaddingTop: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__menu-search--PaddingRight: var(--pf-c-select__menu-item--PaddingRight); + --pf-c-select__menu-search--PaddingBottom: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-search--PaddingLeft: var(--pf-c-select__menu-item--PaddingLeft); + --pf-c-select__menu-group--menu-group--PaddingTop: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__menu-group-title--PaddingTop: var(--pf-c-select__menu-item--PaddingTop); + --pf-c-select__menu-group-title--PaddingRight: var(--pf-c-select__menu-item--PaddingRight); + --pf-c-select__menu-group-title--PaddingBottom: var(--pf-c-select__menu-item--PaddingBottom); + --pf-c-select__menu-group-title--PaddingLeft: var(--pf-c-select__menu-item--PaddingLeft); + --pf-c-select__menu-group-title--FontSize: var(--pf-global--FontSize--xs, 0.75rem); + --pf-c-select__menu-group-title--FontWeight: var(--pf-global--FontWeight--normal, 400); + --pf-c-select__menu-group-title--Color: var(--pf-global--Color--dark-200, #6a6e73); + --pf-c-select__menu-item-count--MarginLeft: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-item-count--FontSize: var(--pf-global--FontSize--sm, 0.875rem); + --pf-c-select__menu-item-count--Color: var(--pf-global--Color--200, #6a6e73); + --pf-c-select__menu-item--disabled__menu-item-count--Color: var(--pf-global--Color--dark-200, #6a6e73); + --pf-c-select__menu-item-description--FontSize: var(--pf-global--FontSize--xs, 0.75rem); + --pf-c-select__menu-item-description--Color: var(--pf-global--Color--200, #6a6e73); + --pf-c-select__menu-item-description--PaddingRight: var(--pf-c-select__menu-item--PaddingRight); + --pf-c-select__menu-item-main--PaddingRight: var(--pf-c-select__menu-item--PaddingRight); + --pf-c-select__menu-item--m-selected__menu-item-main--PaddingRight: var(--pf-c-select__menu-item--m-selected--PaddingRight); + --pf-c-select__menu-footer--BoxShadow: var(--pf-global--BoxShadow--sm-top, 0 -0.125rem 0.25rem -0.0625rem rgba(3, 3, 3, 0.16)); + --pf-c-select__menu-footer--PaddingTop: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-footer--PaddingRight: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-footer--PaddingBottom: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-footer--PaddingLeft: var(--pf-global--spacer--md, 1rem); + --pf-c-select__menu-footer--MarginTop: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select__menu-footer--MarginBottom: calc(var(--pf-global--spacer--sm, 0.5rem) * -1); + --pf-c-select-menu--c-divider--MarginTop: var(--pf-global--spacer--sm, 0.5rem); + --pf-c-select-menu--c-divider--MarginBottom: var(--pf-global--spacer--sm, 0.5rem); +} + +:host, #outer { + display: flex; + flex-direction: column; + align-items: stretch; +} + +:host([hidden]), +*[hidden] { + display: none !important; +} + +:host([disabled]) { + pointer-events: none !important; +} + +#outer.disabled { + color: var(--pf-global--Color--dark-200, #6a6e73) !important; +} + +#outer { + position: relative; +} + +/* TODO(bennyp): see if we can get rid of this wrapping node, for perf reasons */ +#listbox-container { + display: inline-flex; + border: 1px solid var(--pf-global--BorderColor--100, #d2d2d2); + position: absolute; + background-color: var(--pf-theme--color--surface--lightest, #fff) !important; + opacity: 0; + --_active-descendant-color: var(--pf-theme--color--surface--lighter, #f0f0f0) !important +} + +#outer.expanded #listbox-container { + opacity: 1; + z-index: 9999 !important; +} + +#listbox { + display: flex; + flex-direction: column; + position: relative; + width: 100%; +} + +#listbox slot.disabled { + color: var(--pf-c-list__item-icon--Color, #6a6e73) !important; + background-color: var(--pf-theme--color--surface--lighter, #f0f0f0) !important; + border-color: var(--pf-theme--color--surface--lighter, #f0f0f0) !important; + pointer-events: none; + cursor: not-allowed;; + --_active-descendant-color: transparent; + --_svg-color: var(--pf-c-list__item-icon--Color, #6a6e73) !important; +} + + +#toggle { + background-color: var(--pf-theme--color--surface--lightest, #fff) !important; +} + +#toggle, +#toggle-button, +#toggle-input { + display: flex; + align-items: center; + font-family: var(--pf-global--FontFamily--sans-serif, "RedHatTextUpdated", "Overpass", overpass, helvetica, arial, sans-serif); + font-size: var(--pf-global--FontSize--md, 16px); + font-weight: var(--pf-global--FontWeight--normal, 400); + line-height: 1.6; +} + +#toggle { + border: 1px solid var(--pf-global--BorderColor--100, #d2d2d2); + border-bottom-color: var(--pf-theme--color--text, #151515); + justify-content: space-between; +} + +.expanded #toggle { + border-bottom-width: 2px; + border-bottom-color: var(--pf-theme--color--accent, #0066cc); +} + +.disabled #toggle { + color: var(--pf-global--Color--dark-200, #6a6e73) !important; + background-color: var(--pf-theme--color--surface--lighter, #f0f0f0) !important; + border-color: var(--pf-theme--color--surface--lighter, #f0f0f0) !important; +} + +#toggle-input, +#toggle-button { + background: transparent; + border: none; + text-align: left; + border-radius: 0; + flex: 1 0 auto; + min-height: 44px; + min-width: 44px; +} + +#toggle-input { + justify-content: space-between; + padding: var(--pf-global--spacer--xs, 0.25rem) var(--pf-global--spacer--sm, 0.5rem); +} + +.disabled #toggle-input { + pointer-events: none; +} + +#toggle-button { + color: currentColor; + background-color: transparent; + justify-content: flex-end; + padding: var(--pf-global--spacer--sm, 0.5rem); +} + +#outer.typeahead #toggle-button { + flex: 0 0 auto; +} + +#toggle-badge { + flex: 1 0 auto; + margin-inline-start: 0.25em; +} + +#toggle-text { + flex: 1 1 auto; +} + +#toggle-text.badge { + flex: 0 1 auto; +} + +pf-badge { + padding: 0; +} + +#toggle svg { + width: 1em; + height: 1em; + flex: 0 0 auto; + margin-inline-start: 1em; +} + +#description { + display: block; +} + +#listbox.checkboxes { + --_pf-option-checkboxes-display: inline; + --_pf-option-svg-display: none; +} + +::slotted(pf-option-group + hr) { + display: none !important; +} + +::slotted(hr:has(+ pf-option-group)) { + display: none !important; +} + +.offscreen { + position: absolute; + left: -99999; + width: 0; + height: 0; + opacity: 0; + overflow: hidden; +} + +::slotted(hr) { + --pf-c-divider--BorderWidth--base: var(--pf-global--BorderWidth--sm, 1px); + --pf-c-divider--BorderColor--base: var(--pf-c-divider--BackgroundColor); + --pf-c-divider--Height: var(--pf-c-divider--BorderWidth--base); + --pf-c-divider--BackgroundColor: var(--pf-global--BorderColor--100, #d2d2d2); + --pf-c-divider--after--BackgroundColor: var(--pf-c-divider--BorderColor--base); + --pf-c-divider--after--FlexBasis: 100%; + --pf-c-divider--after--Inset: 0%; + --pf-c-divider--m-vertical--after--FlexBasis: 100%; + --pf-c-divider--m-horizontal--Display: flex; + --pf-c-divider--m-horizontal--FlexDirection: row; + --pf-c-divider--m-horizontal--after--Height: var(--pf-c-divider--Height); + --pf-c-divider--m-horizontal--after--Width: auto; + --pf-c-divider--m-vertical--Display: inline-flex; + --pf-c-divider--m-vertical--FlexDirection: column; + --pf-c-divider--m-vertical--after--Height: auto; + --pf-c-divider--m-vertical--after--Width: var(--pf-c-divider--BorderWidth--base); + --pf-hidden-visible--visible--Display: var(--pf-c-divider--Display); + --pf-c-divider--Display: var(--pf-c-divider--m-horizontal--Display); + --pf-c-divider--FlexDirection: var(--pf-c-divider--m-horizontal--FlexDirection); + --pf-c-divider--after--Width: var(--pf-c-divider--m-horizontal--after--Width); + --pf-c-divider--after--Height: var(--pf-c-divider--m-horizontal--after--Height); + display: var(--pf-c-divider--Display, flex); + flex-direction: var(--pf-c-divider--FlexDirection); + border: 0; + width: 100%; + margin-top: var(--pf-c-select-menu--c-divider--MarginTop); + margin-bottom: var(--pf-c-select-menu--c-divider--MarginBottom); +} + +::slotted(hr)::after { + content: ''; + width: var(--pf-c-divider--after--Width, 100%) !important; + height: var(--pf-c-divider--after--Height, 1px); + background-color: var(--pf-c-divider--after--BackgroundColor); + flex: 1 0 100%; +} diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts new file mode 100644 index 0000000000..29aa901167 --- /dev/null +++ b/elements/pf-select/pf-select.ts @@ -0,0 +1,499 @@ +import type { PfChipRemoveEvent } from '@patternfly/elements/pf-chip/pf-chip.js'; + +import { LitElement, html, type PropertyValues } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { query } from 'lit/decorators/query.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import { ListboxController } from '@patternfly/pfe-core/controllers/listbox-controller.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; +import { + FloatingDOMController, + type Placement, +} from '@patternfly/pfe-core/controllers/floating-dom-controller.js'; + +import { PfOption } from './pf-option.js'; + +import styles from './pf-select.css'; +import { SlotController } from '@patternfly/pfe-core/controllers/slot-controller.js'; + +export interface PfSelectUserOptions { + id: string; + value: string; +} + +export class PfSelectChangeEvent extends Event { + constructor() { + super('change', { bubbles: true }); + } +} + +// NOTE: this file contains numerous // comments, which ordinarily would be deleted +// They are here to save the work already done on typeahead, which has a much more complex +// accessibility model, and which is planned for the next release +// * @fires filter - when the filter value changes. used to perform custom filtering + +/** + * A select list enables users to select one or more items from a list. + * + * A select component consists of a toggle control to open and close a menu of actions or links. + * Selects differ from dropdowns in that they persist selection, + * whereas dropdowns are typically used to present a list of actions or links. + * @slot - insert `pf-option` and/or `pf-option-groups` here + * @slot placeholder - placeholder text for the select. Overrides the `placeholder` attribute. + * @fires open - when the menu toggles open + * @fires close - when the menu toggles closed + */ +@customElement('pf-select') +export class PfSelect extends LitElement { + static readonly styles = [styles]; + + static override readonly shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static readonly formAssociated = true; + + #internals = InternalsController.of(this); + + #float = new FloatingDOMController(this, { + content: () => this.shadowRoot?.getElementById('listbox-container') ?? null, + }); + + #slots = new SlotController(this, null, 'placeholder'); + + #listbox?: ListboxController; /* | ListboxActiveDescendantController */ + + /** Variant of rendered Select */ + @property() variant: 'single' | 'checkbox' /* | 'typeahead' | 'typeaheadmulti' */ = 'single'; + + /** + * Accessible label for the select + */ + @property({ attribute: 'accessible-label' }) accessibleLabel?: string; + + /** + * Accessible label for chip group used to describe chips + */ + @property({ + attribute: 'accessible-current-selections-label', + }) accessibleCurrentSelectionsLabel = 'Current selections'; + + /** + * multi listbox button text + */ + @property({ attribute: 'items-selected-text' }) itemsSelectedText = 'items selected'; + + /** + * whether select is disabled + */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** + * Whether the select listbox is expanded + */ + @property({ type: Boolean, reflect: true }) expanded = false; + + /** + * enable to flip listbox when it reaches boundary + */ + @property({ attribute: 'enable-flip', type: Boolean }) enableFlip = false; + + // @property() filter = ''; + + /** Current form value */ + @property() value?: string; + + /** Placeholder entry. Overridden by the `placeholder` slot */ + @property() placeholder?: string; + + /** + * Indicates initial popover position. + * There are 6 options: `bottom`, `top`, `top-start`, `top-end`, `bottom-start`, `bottom-end`. + * Default is `bottom`. + */ + @property({ reflect: true }) position: Placement = 'bottom'; + + /** Flag indicating if selection badge should be hidden for checkbox variant,default false */ + @property({ + attribute: 'checkbox-selection-badge-hidden', + type: Boolean, + }) checkboxSelectionBadgeHidden = false; + + // @property({ attribute: false }) customFilter?: (option: PfOption) => boolean; + + /** + * Single select option value for single select menus, + * or array of select option values for multi select. + */ + set selected(optionsList: PfOption | PfOption[]) { + this.#listbox?.setValue(optionsList); + } + + get selected(): PfOption | PfOption[] | undefined { + return this.#listbox?.value; + } + + /** + * array of slotted options + */ + get options(): PfOption[] { + const opts = Array.from(this.querySelectorAll('pf-option')); + const placeholder = this.shadowRoot?.getElementById('placeholder') as PfOption | null; + if (placeholder) { + return [placeholder, ...opts]; + } else { + return opts; + } + } + + // @query('pf-chip-group') private _chipGroup?: PfChipGroup; + + // @query('#toggle-input') private _input?: HTMLInputElement; + + @query('#toggle-button') private _toggle?: HTMLButtonElement; + + #lastSelected = this.selected; + + get #listboxElement() { + return this.shadowRoot?.getElementById('listbox') ?? null; + } + + /** + * whether select has badge for number of selected items + */ + get #hasBadge() { + // NOTE: revisit this in v5 + return this.variant === 'checkbox' && !this.checkboxSelectionBadgeHidden; + } + + get #buttonLabel() { + switch (this.variant) { + // TODO: implement typeaheadmulti with ActiveDescendantController + // case 'typeaheadmulti': + // return `${this.#listbox?.selectedOptions?.length ?? 0} ${this.itemsSelectedText}` + case 'checkbox': + return this.#listbox + ?.selectedOptions + ?.map?.(option => option.optionText || '') + ?.join(' ') + ?.trim() + || this.#computePlaceholderText() + || 'Options'; + default: + return (this.selected ? this.value : '') + || this.#computePlaceholderText() + || 'Select a value'; + } + } + + override willUpdate(changed: PropertyValues) { + if (this.variant === 'checkbox') { + import('@patternfly/elements/pf-badge/pf-badge.js'); + } + if (changed.has('variant')) { + this.#variantChanged(); + } + if (changed.has('value')) { + this.#internals.setFormValue(this.value ?? ''); + } + if (changed.has('disabled')) { + this.#listbox!.disabled = this.disabled; + } + // TODO: handle filtering in the element, not the controller + // if (changed.has('filter')) { + // this.#listbox.filter = this.filter; + // } + } + + override render() { + const { disabled, expanded, variant } = this; + const { anchor = 'bottom', alignment = 'start', styles = {} } = this.#float; + const { computedLabelText } = this.#internals; + const { height, width } = this.getBoundingClientRect() || {}; + const buttonLabel = this.#buttonLabel; + const hasBadge = this.#hasBadge; + const selectedOptions = this.#listbox?.selectedOptions ?? []; + const typeahead = variant.startsWith('typeahead'); + const checkboxes = variant === 'checkbox'; + const offscreen = typeahead && 'offscreen'; + const badge = hasBadge && 'badge'; + const hasSelection = !!(Array.isArray(this.selected) ? this.selected.length : this.selected); + + return html` +
+
+ ${!(typeahead && selectedOptions.length < 1) ? '' : html` + + ${repeat(selectedOptions, opt => opt.id, opt => html` + ${opt.textContent}`)} + `} + ${!typeahead ? '' : /* TODO: aria attrs */ html` + + `} + +
+
+
+ + ${this.placeholder} + + +
+
+
+ `; + } + + override updated(changed: PropertyValues) { + if (changed.has('expanded')) { + this.#expandedChanged(); + } + if (changed.has('value')) { + this.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); + } + // whether select has removable chips for selected items + // NOTE: revisit this in v5 + // const hasChips = this.variant === 'typeaheadmulti'; + // reset input if chip has been added + // if (this.hasChips && this._input?.value) { + // const chip = this.shadowRoot?.querySelector(`pf-chip#chip-${this._input?.value}`) as HTMLElement; + // if (chip && this._chipGroup) { + // this._chipGroup.focusOnChip(chip); + // this._input.value = ''; + // } + // } + } + + override firstUpdated() { + // kick the renderer to that the placeholder gets picked up + this.requestUpdate(); + // TODO: don't do filtering in the controller + // if (this.variant === 'typeaheadmulti') { + // this.#listbox.filter = this.filter; + // } + } + + #variantChanged() { + this.#listbox?.hostDisconnected(); + const getHTMLElement = () => this.#listboxElement; + switch (this.variant) { + // TODO + // case 'typeahead': + // case 'typeaheadmulti': + // this.#controller = new ListboxController.of(this, { + // multi: this.variant==='typeaheadmulti', + // a11yController: ActiveDescendantController.of(this) + // }); + // break; + default: + this.#listbox = ListboxController.of(this, { + multi: this.variant === 'checkbox', + getHTMLElement, + isSelected: option => option.selected, + requestSelect: (option, selected) => { + this.#lastSelected = this.selected; + option.selected = !option.disabled && !!selected; + this.#selectedChanged(); + return true; + }, + a11yController: RovingTabindexController.of(this, { + getHTMLElement, + getItems: () => this.options, + }), + }); + break; + } + } + + async #expandedChanged() { + const will = this.expanded ? 'close' : 'open'; + this.dispatchEvent(new Event(will)); + if (this.expanded) { + await this.#float.show({ placement: this.position || 'bottom', flip: !!this.enableFlip }); + const focusableItem = this.#listbox?.activeItem ?? this.#listbox?.nextItem; + focusableItem?.focus(); + } else if (this.#lastSelected === this.selected) { + await this.#float.hide(); + this._toggle?.focus(); + } + } + + async #selectedChanged() { + await this.updateComplete; + this.value = [this.selected] + .flat() + .filter(x => !!x) + .map(x => x!.value) + .join(); + this.dispatchEvent(new PfSelectChangeEvent()); + switch (this.variant) { + case 'single': + this.hide(); + this._toggle?.focus(); + } + } + + #onListboxKeydown(event: KeyboardEvent) { + switch (event.key) { + case 'Escape': + this.hide(); + this._toggle?.focus(); + } + } + + #onListboxFocusout(event: FocusEvent) { + switch (this.variant) { + case 'single': + case 'checkbox': + if (this.expanded) { + const root = this.getRootNode(); + if (root instanceof ShadowRoot + || root instanceof Document + && !this.options.includes(event.relatedTarget as PfOption) + ) { + this.hide(); + } + } + } + } + + #onButtonKeydown(event: KeyboardEvent) { + switch (this.variant) { + case 'single': + case 'checkbox': + switch (event.key) { + case 'ArrowDown': + this.show(); + } + } + } + + #onListboxSlotchange() { + this.#listbox?.setOptions(this.options); + this.options.forEach((option, index, options) => { + option.setSize = options.length; + option.posInSet = index; + }); + } + + /** + * handles chip's remove button clicking + * @param opt chip text to be removed from values + */ + #onChipRemove(opt: PfOption, event: PfChipRemoveEvent) { + // if (event.chip) { + // opt.selected = false; + // this._input?.focus(); + // } + } + + /** + * handles typeahead combobox input event + */ + #onTypeaheadInput() { + // update filter + // if (this.filter !== this._input?.value) { + // this.filter = this._input?.value || ''; + // this.show(); + // } + // TODO: handle hiding && aria hiding options + } + + #computePlaceholderText() { + return this.placeholder + || this.querySelector('[slot=placeholder]') + ?.assignedNodes() + ?.reduce((acc, node) => `${acc}${node.textContent}`, '')?.trim() + || this.#listbox?.options + ?.filter(x => x !== this.shadowRoot?.getElementById('placeholder')) + ?.at(0)?.value + || ''; + } + + /** + * Opens the dropdown + */ + async show() { + this.expanded = true; + await this.updateComplete; + } + + /** + * Closes listbox + */ + async hide() { + this.expanded = false; + await this.updateComplete; + } + + /** + * toggles popup based on current state + */ + async toggle() { + this.expanded = !this.expanded; + await this.updateComplete; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-select': PfSelect; + } +} diff --git a/elements/pf-select/test/pf-select.e2e.ts b/elements/pf-select/test/pf-select.e2e.ts new file mode 100644 index 0000000000..4bbc30693f --- /dev/null +++ b/elements/pf-select/test/pf-select.e2e.ts @@ -0,0 +1,12 @@ +import { test } from '@playwright/test'; +import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js'; + +const tagName = 'pf-select'; + +test.describe(tagName, () => { + test('snapshot', async ({ page }) => { + const componentPage = new PfeDemoPage(page, tagName); + await componentPage.navigate(); + await componentPage.snapshot(); + }); +}); diff --git a/elements/pf-select/test/pf-select.spec.ts b/elements/pf-select/test/pf-select.spec.ts new file mode 100644 index 0000000000..f2f03adcef --- /dev/null +++ b/elements/pf-select/test/pf-select.spec.ts @@ -0,0 +1,961 @@ +import { expect, html, nextFrame } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { PfSelect } from '../pf-select.js'; +import { sendKeys } from '@web/test-runner-commands'; +import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; + +async function shiftHold() { + await sendKeys({ down: 'Shift' }); +} + +async function shiftRelease() { + await sendKeys({ up: 'Shift' }); +} + +async function ctrlA() { + await sendKeys({ down: 'Control' }); + await sendKeys({ down: 'a' }); + await sendKeys({ up: 'a' }); + await sendKeys({ up: 'Control' }); +} + +function press(key: string) { + return async function() { + await sendKeys({ press: key }); + }; +} + +function getValues(element: PfSelect) { + return [element.selected].flat().filter(x => !!x).map(x => x!.value); +} + +describe('', function() { + let element: PfSelect; + + const updateComplete = () => element.updateComplete; + + const focus = () => element.focus(); + + describe('simply instantiating', function() { + it('imperatively instantiates', function() { + expect(document.createElement('pf-select')).to.be.an.instanceof(PfSelect); + }); + + it('should upgrade', async function() { + element = await createFixture(html``); + const klass = customElements.get('pf-select'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfSelect); + }); + }); + + describe('variant="single"', function() { + beforeEach(async function() { + element = await createFixture(html` + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + `); + }); + + it('is accessible', async function() { + await expect(element).to.be.accessible(); + }); + + describe('without accessible label', function() { + beforeEach(function() { + element.accessibleLabel = undefined; + }); + beforeEach(updateComplete); + it('fails accessibility audit', async function() { + await expect(element).to.not.be.accessible(); + }); + }); + + describe('calling focus())', function() { + beforeEach(function() { + element.focus(); + }); + + beforeEach(updateComplete); + + describe('pressing Enter', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + + it('expands', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox).to.be.ok; + }); + + it('focuses on the placeholder', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('Choose a number'); + }); + }); + + describe('pressing Space', function() { + beforeEach(press(' ')); + beforeEach(updateComplete); + + it('expands', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)).to.be.ok; + expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + }); + + it('focuses on the placeholder', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('Choose a number'); + }); + }); + + describe('pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + + it('expands', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox).to.be.ok; + }); + + it('focuses on option 1', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('Choose a number'); + }); + + describe('then pressing ArrowUp', function() { + beforeEach(press('ArrowUp')); + beforeEach(updateComplete); + it('focuses on the last option', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('8'); + }); + describe('then pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + it('focuses on the placeholder', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('Choose a number'); + }); + }); + }); + + describe('then pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + + it('focuses on option 1', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('1'); + }); + + describe('then pressing ArrowUp', function() { + beforeEach(press('ArrowUp')); + beforeEach(updateComplete); + it('focuses on the placeholder', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('Choose a number'); + }); + }); + + describe('then pressing Enter', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + + it('selects option 1', function() { + expect(getValues(element)).to.deep.equal(['1']); + }); + }); + }); + + describe('then pressing Space', function() { + beforeEach(press(' ')); + beforeEach(updateComplete); + + it('closes', function() { + expect(element.expanded).to.be.false; + }); + + it('hides the listbox', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'listbox')).to.be.undefined; + }); + + it('focuses the button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + expect(focused?.haspopup).to.equal('listbox'); + }); + + it('does not select anything', async function() { + // because the placeholder was focused + expect(getValues(element)).to.deep.equal([]); + }); + }); + + describe('then pressing Tab', function() { + beforeEach(press('Tab')); + beforeEach(nextFrame); + beforeEach(updateComplete); + it('closes', function() { + expect(element.expanded).to.be.false; + }); + it('hides the listbox', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)).to.be.undefined; + }); + it('focuses the button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + expect(focused?.haspopup).to.equal('listbox'); + }); + }); + + describe('then pressing Shift+Tab', function() { + beforeEach(shiftHold); + beforeEach(press('Tab')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + it('closes', function() { + expect(element.expanded).to.be.false; + }); + it('hides the listbox', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox).to.be.undefined; + }); + it('focuses the button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + expect(focused?.haspopup).to.equal('listbox'); + }); + }); + + describe('then pressing Escape', function() { + beforeEach(press('Escape')); + beforeEach(nextFrame); + beforeEach(updateComplete); + it('closes', function() { + expect(element.expanded).to.be.false; + }); + it('hides the listbox', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)).to.be.undefined; + }); + it('focuses the button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + expect(focused?.haspopup).to.equal('listbox'); + }); + }); + }); + }); + }); + + describe('variant="checkbox"', function() { + beforeEach(async function() { + element = await createFixture(html` + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + `); + }); + + it('is accessible', async function() { + await expect(element).to.be.accessible(); + }); + + describe('calling focus())', function() { + beforeEach(function() { + element.focus(); + }); + + beforeEach(updateComplete); + describe('pressing Enter', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + + it('expands', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)).to.be.ok; + expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + }); + + it('should NOT use checkbox role for options', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)?.children?.filter(x => x.role === 'checkbox')?.length) + .to.equal(0); + }); + }); + + describe('pressing Space', function() { + beforeEach(press(' ')); + beforeEach(updateComplete); + it('expands', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)).to.be.ok; + expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + }); + }); + + describe('pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + it('expands', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)).to.be.ok; + expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + }); + + describe('then pressing Shift+Tab', function() { + beforeEach(shiftHold); + beforeEach(press('Tab')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + + it('closes', async function() { + expect(element.expanded).to.be.false; + }); + + it('hides the listbox', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)).to.be.undefined; + }); + + it('focuses the button', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(0)?.role).to.equal('combobox'); + expect(snapshot.children?.at(0)?.focused).to.be.true; + }); + }); + + describe('then pressing Tab', function() { + beforeEach(press('Tab')); + beforeEach(nextFrame); + beforeEach(updateComplete); + // a little extra sleep to de-flake this test + beforeEach(nextFrame); + beforeEach(updateComplete); + it('closes', function() { + expect(element.expanded).to.be.false; + }); + it('hides the listbox', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox).to.be.undefined; + }); + }); + + describe('then pressing Escape', function() { + beforeEach(press('Escape')); + beforeEach(updateComplete); + it('closes', function() { + expect(element.expanded).to.be.false; + }); + it('hides the listbox', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)).to.be.undefined; + }); + it('focuses the button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + }); + }); + + describe('then pressing Space', function() { + beforeEach(press(' ')); + beforeEach(updateComplete); + + it('selects option 1', function() { + // because the placeholder was focused + expect(getValues(element)).to.deep.equal(['1']); + }); + + it('remains expanded', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + }); + + describe('then pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + it('focuses option 1', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('2'); + }); + describe('then pressing Enter', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('adds option 2 to selection', function() { + expect(getValues(element)).to.deep.equal([ + '1', + '2', + ]); + }); + + it('remains expanded', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.at(1)?.role).to.equal('listbox'); + }); + + describe('then holding Shift and pressing down arrow / enter twice in a row', function() { + beforeEach(shiftHold); + beforeEach(press('ArrowDown')); + beforeEach(press('Enter')); + beforeEach(press('ArrowDown')); + beforeEach(press('Enter')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + + it('adds options 2 and 3 to the selected list', function() { + expect(getValues(element)).to.deep.equal([ + '1', + '2', + '3', + '4', + ]); + }); + + describe('then pressing ArrowUp and Enter', function() { + beforeEach(press('ArrowUp')); + beforeEach(press('Enter')); + beforeEach(updateComplete); + + it('deselects option 3', function() { + expect(getValues(element)).to.deep.equal([ + '1', + '2', + '4', + ]); + }); + + describe('then holding down Shift and pressing arrow up / enter twice in a row', function() { + beforeEach(press('ArrowUp')); + beforeEach(press('Enter')); + beforeEach(updateComplete); + beforeEach(press('ArrowUp')); + beforeEach(press('Enter')); + beforeEach(updateComplete); + beforeEach(shiftRelease); + beforeEach(updateComplete); + + it('deselects options 1 and 2', function() { + expect(getValues(element)).to.deep.equal([ + '4', + ]); + }); + + describe('then pressing Ctrl+A', function() { + beforeEach(ctrlA); + beforeEach(updateComplete); + + it('selects all options', function() { + expect(getValues(element)).to.deep.equal([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + ]); + }); + + describe('then pressing Ctrl+A again', function() { + beforeEach(ctrlA); + beforeEach(updateComplete); + it('deselects all options', function() { + expect(getValues(element)).to.deep.equal([]); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + // try again when we implement activedescendant + describe.skip('variant="typeahead"', function() { + beforeEach(async function() { + element = await createFixture(html` + + Blue + Green + Magenta + Orange + Purple + Pink + Red + Yellow + `); + }); + + describe('custom filtering', function() { + beforeEach(function() { + // @ts-expect-error: we intend to implement this in the next release + element.customFilter = option => + // @ts-expect-error: TODO add filter feature + new RegExp(element.filter).test(option.value); + }); + + beforeEach(focus); + + beforeEach(updateComplete); + + describe('typing "r"', function() { + beforeEach(press('r')); + beforeEach(updateComplete); + it('shows options with "r" anywhere in them', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox?.children?.length).to.equal(3); + expect(listbox?.children?.at(0)?.name).to.equal('Green'); + expect(listbox?.children?.at(1)?.name).to.equal('Orange'); + expect(listbox?.children?.at(2)?.name).to.equal('Purple'); + }); + }); + + describe('typing "R"', function() { + beforeEach(press('R')); + beforeEach(nextFrame); + beforeEach(updateComplete); + it('shows options that contain "R"', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox?.children?.length).to.equal(1); + expect(listbox?.children?.at(0)?.name).to.equal('Red'); + }); + }); + }); + + describe('calling focus()', function() { + beforeEach(focus); + + beforeEach(updateComplete); + + it('has a text input for typeahead', async function() { + const snapshot = await a11ySnapshot(); + const [typeahead] = snapshot.children ?? []; + expect(typeahead).to.deep.equal({ + role: 'combobox', + name: 'Options', + focused: true, + autocomplete: 'both', + haspopup: 'listbox', + }); + }); + + describe('typing "r"', function() { + beforeEach(press('r')); + beforeEach(updateComplete); + + it('only shows options that start with "r" or "R"', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox?.children?.every(x => x.name.toLowerCase().startsWith('r'))).to.be.true; + }); + }); + + describe('setting filter to "*"', function() { + beforeEach(function() { + // @ts-expect-error: todo: add filter feature + element.filter = '*'; + }); + beforeEach(updateComplete); + it('does not error', async function() { + const snapshot = await a11ySnapshot(); + const [, , listbox] = snapshot.children ?? []; + expect(listbox?.children).to.not.be.ok; + }); + }); + + describe('changing input value to "p"', function() { + beforeEach(press('p')); + beforeEach(updateComplete); + + it('only shows listbox items starting with the letter p', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox?.children?.length).to.equal(2); + expect(listbox?.children?.at(0)?.name).to.equal('Purple'); + expect(listbox?.children?.at(1)?.name).to.equal('Pink'); + }); + + it('maintains focus on the input', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + }); + + describe('pressing Backspace so input value is ""', function() { + beforeEach(press('Backspace')); + beforeEach(updateComplete); + + it('all options are visible', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox?.children?.length).to.equal(8); + expect(listbox?.children?.at(0)?.name).to.equal('Blue'); + expect(listbox?.children?.at(1)?.name).to.equal('Green'); + expect(listbox?.children?.at(2)?.name).to.equal('Magenta'); + expect(listbox?.children?.at(3)?.name).to.equal('Orange'); + expect(listbox?.children?.at(4)?.name).to.equal('Purple'); + expect(listbox?.children?.at(5)?.name).to.equal('Pink'); + expect(listbox?.children?.at(6)?.name).to.equal('Red'); + expect(listbox?.children?.at(7)?.name).to.equal('Yellow'); + }); + }); + }); + + describe('pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(nextFrame); + beforeEach(updateComplete); + it('expands', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox).to.be.ok; + }); + it('selects the first item', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused).to.not.be.ok; + const selected = listbox?.children?.find(x => x.selected); + expect(selected).to.be.ok; + expect(listbox?.children?.at(0)).to.equal(selected); + }); + it('does not move keyboard focus', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused).to.not.be.ok; + }); + describe('then pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + it('focuses the first option', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused).to.be.ok; + expect(listbox?.children?.indexOf(focused!)).to.equal(0); + }); + describe('then pressing Enter', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('selects the second option', function() { + expect(getValues(element)).to.deep.equal(['Green']); + }); + it('sets typeahead input to second option value', async function() { + const snapshot = await a11ySnapshot(); + const [combobox] = snapshot.children ?? []; + expect(combobox?.value).to.equal('Green'); + }); + it('focuses on toggle button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('button'); + expect(focused?.haspopup).to.equal('listbox'); + }); + it('closes', async function() { + expect(element.expanded).to.be.false; + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox).to.be.undefined; + }); + }); + }); + }); + }); + }); + + // try again when we implement activedescendant + describe.skip('variant="typeaheadmulti"', function() { + beforeEach(async function() { + element = await createFixture(html` + + Amethyst + Beryl + Chalcedony + Diamond + Emerald + Fool's Gold + Garnet + Halite + Iris + `); + }); + + describe('calling focus()', function() { + beforeEach(function() { + element.focus(); + }); + beforeEach(updateComplete); + + it('focuses the typeahead input', async function() { + const snapshot = await a11ySnapshot(); + const [input] = snapshot.children ?? []; + expect(input.focused).to.be.true; + expect(input.role).to.equal('combobox'); + }); + + describe('pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + + it('expands', function() { + expect(element.expanded).to.be.true; + }); + + it('shows the listbox', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'listbox')).to.be.ok; + }); + + it('focuses the first option', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox?.children?.find(x => x.focused)?.name).to.equal('Amethyst'); + }); + + describe('then pressing Shift+Tab', function() { + beforeEach(shiftHold); + beforeEach(press('Tab')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + it('closes', function() { + expect(element.expanded).to.be.false; + }); + + it('hides the listbox', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'listbox')).to.be.undefined; + }); + + it('focuses the toggle button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot?.children?.find(x => x.focused); + expect(focused?.role).to.equal('button'); + expect(focused?.haspopup).to.equal('listbox'); + }); + + describe('then pressing Shift+Tab', function() { + beforeEach(shiftHold); + beforeEach(press('Tab')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + it('focuses the combobox input', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot?.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + expect(focused?.haspopup).to.equal('listbox'); + }); + }); + }); + + describe('then pressing ArrowDown', function() { + beforeEach(press('ArrowDown')); + beforeEach(updateComplete); + describe('then pressing Enter', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('selects the second option', function() { + expect(getValues(element)).to.deep.equal(['Beryl']); + }); + it('focuses on second option', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox?.children?.find(x => x.focused)?.name).to.equal('Beryl'); + }); + it('remains expanded', async function() { + expect(element.expanded).to.be.true; + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + expect(listbox).to.be.ok; + }); + it('shows 1 chip', async function() { + const snapshot = await a11ySnapshot(); + const [, chip1close] = snapshot.children ?? []; + expect(chip1close?.role).to.equal('button'); + expect(chip1close?.name).to.equal('Close'); + expect(chip1close?.description).to.equal('Beryl'); + }); + describe('then pressing ArrowUp', function() { + beforeEach(press('ArrowUp')); + beforeEach(updateComplete); + it('focuses the first option', async function() { + const snapshot = await a11ySnapshot(); + const listbox = snapshot.children?.find(x => x.role === 'listbox'); + const focused = listbox?.children?.find(x => x.focused); + expect(focused?.name).to.equal('Amethyst'); + }); + describe('then pressing Enter', function() { + beforeEach(press('Enter')); + beforeEach(updateComplete); + it('adds second option to selected values', function() { + expect(getValues(element)).to.deep.equal(['Amethyst', 'Beryl']); + }); + it('accessible combo button label should be "2 items selected"', async function() { + const snapshot = await a11ySnapshot(); + const button = snapshot.children?.find(x => x.role === 'combobox'); + expect(button?.name).to.equal('2 items selected'); + }); + it('shows 2 chips', async function() { + const snapshot = await a11ySnapshot(); + const [, chip1close, , chip2close] = snapshot.children ?? []; + expect(chip1close?.role).to.equal('button'); + expect(chip1close?.name).to.equal('Close'); + expect(chip1close?.description).to.equal('Amethyst'); + expect(chip2close?.role).to.equal('button'); + expect(chip2close?.name).to.equal('Close'); + expect(chip2close?.description).to.equal('Beryl'); + }); + describe('then pressing Shift+Tab', function() { + beforeEach(shiftHold); + beforeEach(press('Tab')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + it('focuses the toggle button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('button'); + expect(focused?.haspopup).to.equal('listbox'); + }); + describe('then pressing Shift+Tab', function() { + beforeEach(shiftHold); + beforeEach(press('Tab')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + it('focuses the combobox input', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + }); + describe('then pressing Shift+Tab', function() { + beforeEach(shiftHold); + beforeEach(press('Tab')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + it('focuses the last chip\'s close button', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('button'); + expect(focused?.name).to.equal('Close'); + expect(focused?.description).to.equal('Beryl'); + }); + describe('then pressing Space', function() { + beforeEach(updateComplete); + beforeEach(press(' ')); + beforeEach(updateComplete); + beforeEach(updateComplete); + it('removes the second chip', async function() { + const snapshot = await a11ySnapshot(); + const [, chip1close, ...rest] = snapshot.children ?? []; + expect(chip1close?.role).to.equal('button'); + expect(chip1close?.name).to.equal('Close'); + expect(chip1close?.description).to.equal('Amethyst'); + expect(rest.filter(x => 'description' in x)?.length).to.equal(0); + }); + it('removes the second option from the selected values', function() { + expect(getValues(element)).to.deep.equal(['Amethyst']); + }); + it('focuses the combobox', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + }); + describe('then pressing Shift+Tab', function() { + beforeEach(shiftHold); + beforeEach(press('Tab')); + beforeEach(shiftRelease); + beforeEach(updateComplete); + it('focuses the first chip', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('button'); + expect(focused?.description).to.equal('Amethyst'); + }); + describe('then pressing Space', function() { + beforeEach(press(' ')); + beforeEach(updateComplete); + it('removes all chips', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot.children?.find(x => x.role === 'button' && x.name === 'Close')) + .to.be.undefined; + }); + it('focuses the typeahead input', async function() { + const snapshot = await a11ySnapshot(); + const focused = snapshot.children?.find(x => x.focused); + expect(focused?.role).to.equal('combobox'); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/elements/pf-spinner/BaseSpinner.css b/elements/pf-spinner/BaseSpinner.css deleted file mode 100644 index ef5b8cde05..0000000000 --- a/elements/pf-spinner/BaseSpinner.css +++ /dev/null @@ -1,20 +0,0 @@ -:host { - display: inline-block; - width: min-content; - min-height: 0; - aspect-ratio: 1 / 1; -} - -svg { - overflow: hidden; -} - -circle { - width: 100%; - height: 100%; - transform-origin: 50% 50%; - stroke-linecap: round; - stroke-dasharray: 283; - stroke-dashoffset: 280; -} - diff --git a/elements/pf-spinner/BaseSpinner.ts b/elements/pf-spinner/BaseSpinner.ts deleted file mode 100644 index 5cbb3c3b9c..0000000000 --- a/elements/pf-spinner/BaseSpinner.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { LitElement, html } from 'lit'; -import { property } from 'lit/decorators/property.js'; - -import styles from './BaseSpinner.css'; - -export type SpinnerSize = ( - | 'sm' - | 'md' - | 'lg' - | 'xl' -); - -/** - * Base spinner class - * - * @cssprop {} --pf-c-spinner--diameter {@default `3.375rem`} - * @cssprop {} --pf-c-spinner--Width {@default `3.375rem`} - * @cssprop {} --pf-c-spinner--Height {@default `3.375rem`} - * @cssprop {} --pf-c-spinner--Color {@default `#06c`} - * @cssprop {} --pf-c-spinner--m-sm--diameter {@default `0.625rem`} - * @cssprop {} --pf-c-spinner--m-md--diameter {@default `1.125rem`} - * @cssprop {} --pf-c-spinner--m-lg--diameter {@default `1.5rem`} - * @cssprop {} --pf-c-spinner--m-xl--diameter {@default `3.375rem`} - * @cssprop {
+ + + + diff --git a/elements/pf-tabs/demo/pf-tabs.js b/elements/pf-tabs/demo/pf-tabs.js deleted file mode 100644 index 55ae934a57..0000000000 --- a/elements/pf-tabs/demo/pf-tabs.js +++ /dev/null @@ -1,43 +0,0 @@ -import '@patternfly/elements/pf-icon/pf-icon.js'; -import '@patternfly/elements/pf-switch/pf-switch.js'; -import '@patternfly/elements/pf-tabs/pf-tabs.js'; - -const toggleVariant = document.getElementById('toggle-variant'); -const resize = document.getElementById('overflow'); -const verticalInput = document.getElementById('toggle-vertical'); -const resizeInput = document.getElementById('toggle-resize'); -const verticalVariant = document.querySelector('pf-tabs[vertical]'); -const boxVariant = document.querySelector('pf-tabs[box]'); -const inset = document.querySelector('#inset'); - -function variantToggle() { - boxVariant.setAttribute('box', toggleVariant.checked ? 'dark' : 'light'); -} - -function verticalToggle() { - if (verticalInput.checked) { - verticalVariant.setAttribute('box', 'dark'); - } else { - verticalVariant.removeAttribute('box'); - } -} - -function resizeToggle() { - if (resizeInput.checked) { - resize.setAttribute('box', 'dark'); - } else { - resize.removeAttribute('box'); - } -} - -function insetToggle(event) { - inset.classList = event.target.value; -} - -for (const input of document.querySelectorAll('input[name="toggle-inset"]')) { - input.addEventListener('change', insetToggle); -} - -toggleVariant.addEventListener('change', variantToggle); -resizeInput.addEventListener('change', resizeToggle); -verticalInput.addEventListener('change', verticalToggle); diff --git a/elements/pf-tabs/demo/tabs-first-in-markup.html b/elements/pf-tabs/demo/tabs-first-in-markup.html new file mode 100644 index 0000000000..205a3f49c2 --- /dev/null +++ b/elements/pf-tabs/demo/tabs-first-in-markup.html @@ -0,0 +1,33 @@ +
+ + Users + Containers + Database + Disabled + Users + Containers + Database + Disabled + +
+ + + + diff --git a/elements/pf-tabs/demo/vertical.html b/elements/pf-tabs/demo/vertical.html new file mode 100644 index 0000000000..3481cfde39 --- /dev/null +++ b/elements/pf-tabs/demo/vertical.html @@ -0,0 +1,51 @@ +
+ + Users + Users + Containers + Containers + Database + Database + Disabled + Disabled + + +
+ Box variant: + + + +
+
+ + + + diff --git a/elements/pf-tabs/docs/pf-tabs.md b/elements/pf-tabs/docs/pf-tabs.md index 49bc44ace3..a8a36817ca 100644 --- a/elements/pf-tabs/docs/pf-tabs.md +++ b/elements/pf-tabs/docs/pf-tabs.md @@ -23,8 +23,6 @@ Database Disabled Disabled - Aria Disabled - Aria Disabled
{% endrenderOverview %} @@ -140,8 +138,6 @@ export const Expander = () => ( Database Disabled Disabled - Aria Disabled - Aria Disabled {% endhtmlexample %} @@ -156,8 +152,6 @@ export const Expander = () => ( Database Disabled Disabled - Aria Disabled - Aria Disabled {% endhtmlexample %} @@ -172,8 +166,6 @@ export const Expander = () => ( Database Disabled Disabled - Aria Disabled - Aria Disabled {% endhtmlexample %} diff --git a/elements/pf-tabs/pf-tab-panel.css b/elements/pf-tabs/pf-tab-panel.css index a478c3422f..e7da6b0e29 100644 --- a/elements/pf-tabs/pf-tab-panel.css +++ b/elements/pf-tabs/pf-tab-panel.css @@ -1,3 +1,11 @@ +:host { + display: block; +} + +:host([hidden]) { + display: none; +} + :host([box="light"]) { background-color: var(--pf-c-tab-content--m-light-300, var(--pf-global--BackgroundColor--light-300, #f0f0f0)); } diff --git a/elements/pf-tabs/pf-tab-panel.ts b/elements/pf-tabs/pf-tab-panel.ts index b272e571aa..d70f883310 100644 --- a/elements/pf-tabs/pf-tab-panel.ts +++ b/elements/pf-tabs/pf-tab-panel.ts @@ -1,19 +1,58 @@ +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; +import { state } from 'lit/decorators/state.js'; +import { consume } from '@lit/context'; -import styles from './pf-tab-panel.css'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; + +import { type PfTabsContext, context } from './context.js'; -import { BaseTabPanel } from './BaseTabPanel.js'; +import styles from './pf-tab-panel.css'; /** * @slot - Tab panel content - * * @cssprop {} --pf-c-tab-content--m-light-300 {@default `#f0f0f0`} - * * @csspart container - container for the panel content */ @customElement('pf-tab-panel') -export class PfTabPanel extends BaseTabPanel { - static readonly styles = [...BaseTabPanel.styles, styles]; +export class PfTabPanel extends LitElement { + static readonly styles = [styles]; + + @consume({ context, subscribe: true }) + @state() private ctx?: PfTabsContext; + + render() { + return html` + + `; + } + + override connectedCallback() { + super.connectedCallback(); + this.id ||= getRandomId('pf-tab-panel'); + this.hidden ??= true; + + /* + To make it easy for screen reader users to navigate from a tab + to the beginning of content in the active tabpanel, the tabpanel + element has tabindex="0" to include the panel in the page Tab sequence. + It is recommended that all tabpanel elements in a tab set are focusable + if there are any panels in the set that contain content where the first + element in the panel is not focusable. + https://www.w3.org/WAI/ARIA/apg/example-index/tabs/tabs-automatic + */ + this.tabIndex = 0; + } + + override willUpdate() { + const { box, vertical } = this.ctx ?? {}; + this.toggleAttribute('vertical', vertical); + if (box) { + this.setAttribute('box', box); + } else { + this.removeAttribute('box'); + } + } } declare global { diff --git a/elements/pf-tabs/pf-tab.css b/elements/pf-tabs/pf-tab.css index d36f3990f6..2c7aae877a 100644 --- a/elements/pf-tabs/pf-tab.css +++ b/elements/pf-tabs/pf-tab.css @@ -1,23 +1,44 @@ +[hidden] { + display: none !important; +} + :host { + display: flex; + flex: none; + outline: none; scroll-snap-align: var(--pf-c-tabs__item--ScrollSnapAlign, end); } -:host([active]) { +.active { --pf-c-tabs__link--Color: var(--pf-c-tabs__item--m-current__link--Color, var(--pf-global--Color--100, #151515)); --pf-c-tabs__link--after--BorderColor: var(--pf-c-tabs__item--m-current__link--after--BorderColor, var(--pf-global--active-color--100, #06c)); --pf-c-tabs__link--after--BorderWidth: var(--pf-c-tabs__item--m-current__link--after--BorderWidth, var(--pf-global--BorderWidth--lg, 3px)); } -:host([box][active]) { +.box.active { --pf-c-tabs__link--BackgroundColor: var(--pf-c-tabs__item--m-current__link--BackgroundColor, var(--pf-global--BackgroundColor--100, #ffffff)); --pf-c-tabs__link--before--BorderBottomColor: var(--pf-c-tabs__link--BackgroundColor, transparent); } -:host(.first[box][active]) #current::before { - left: calc(var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px)) * -1); +.vertical [part="text"] { + max-width: 100%; + overflow-wrap: break-word; } -button { +slot[name="icon"] { + display: block; +} + +#button { + margin: 0; + font-family: inherit; + font-size: 100%; + border: 0; + position: relative; + display: flex; + flex: 1; + text-decoration: none; + cursor: pointer; align-items: center; gap: var(--pf-c-tabs__link--child--MarginRight, var(--pf-global--spacer--md, 1rem)); line-height: var(--pf-global--LineHeight--md, 1.5); @@ -34,7 +55,22 @@ button { background-color: var(--pf-c-tabs__link--BackgroundColor, transparent); } -button::before { +#button::before, +#button::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: ""; + border-style: solid; + padding: 0; + margin: 0; + background-color: transparent; + pointer-events: none; +} + +#button::before { border-block-start-width: var(--pf-c-tabs__link--before--BorderTopWidth, 0); border-inline-end-width: var(--pf-c-tabs__link--before--BorderRightWidth, 0); border-block-end-width: var(--pf-c-tabs__link--before--BorderBottomWidth, 0); @@ -45,7 +81,7 @@ button::before { border-inline-start-color: var(--pf-c-tabs__link--before--BorderLeftColor, var(--pf-c-tabs__link--before--border-color--base, var(--pf-global--BorderColor--100, #d2d2d2))); } -button::after { +#button::after { top: var(--pf-c-tabs__link--after--Top, auto); right: var(--pf-c-tabs__link--after--Right, 0); bottom: var(--pf-c-tabs__link--after--Bottom, 0); @@ -57,31 +93,44 @@ button::after { border-inline-start-width: var(--pf-c-tabs__link--after--BorderLeftWidth); } -button:hover { +:host(:hover) #button { --pf-c-tabs__link-toggle-icon--Color: var(--pf-c-tabs__link--hover__toggle-icon--Color); --pf-c-tabs__link--after--BorderWidth: var(--pf-c-tabs__link--hover--after--BorderWidth, var(--pf-global--BorderWidth--lg, 3px)); } -button:focus, -button:focus-visible { +:host(:is(:focus, :focus-visible)) #button { + outline-width: 1px; + outline-style: auto; outline-color: var(--pf-c-tabs__link--after--BorderColor, #06c); --pf-c-tabs__link--after--BorderWidth: var(--pf-c-tabs__link--focus--after--BorderWidth, var(--pf-global--BorderWidth--lg, 3px)); } -button:active { +:host(:active) #button { --pf-c-tabs__link--after--BorderWidth: var(--pf-c-tabs__link--active--after--BorderWidth, var(--pf-global--BorderWidth--lg, 3px)); } -:host([box]) button { +.fill #button { + flex-basis: 100%; + justify-content: center; +} + +:host(:disabled) #button { + pointer-events: none; +} + +:host([aria-disabled="true"]) #button { + cursor: default; +} + +.box #button { --pf-c-tabs__link--after--BorderTopWidth: var(--pf-c-tabs__link--after--BorderWidth, 0); } -:host([box]) button, -:host([vertical]) button { +:is(.box, .vertical) #button { --pf-c-tabs__link--after--BorderBottomWidth: 0; } -:host([vertical]) button { +.vertical #button { --pf-c-tabs__link--after--Bottom: 0; --pf-c-tabs__link--after--BorderTopWidth: 0; --pf-c-tabs__link--after--BorderLeftWidth: var(--pf-c-tabs__link--after--BorderWidth, 0); @@ -89,32 +138,32 @@ button:active { text-align: left; } -:host([box][vertical]) button::after { +.box.vertical #button::after { top: calc(var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px)) * -1); } -:host(.first[box][vertical]) button::after, -:host([box][vertical][active]) button::after { +:host(:first-of-type) .box.vertical #button::after, +.box.vertical.active #button::after { top: 0; } -:host([box][vertical][active]) button::before { +.box.vertical.active #button::before { --pf-c-tabs__link--before--BorderRightColor: var(--pf-c-tabs__item--m-current__link--BackgroundColor, var(--pf-global--BackgroundColor--100, #ffffff)); --pf-c-tabs__link--before--BorderBottomWidth: var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px)); --pf-c-tabs__link--before--BorderBottomColor: var(--pf-c-tabs__link--before--border-color--base, var(--pf-global--BorderColor--100, #d2d2d2)); } -:host(.first[box][active]) button::before { +:host(:first-of-type) .box.active #button::before { border-block-start-width: var(--pf-c-tabs--m-box__item--m-current--first-child__link--before--BorderTopWidth, var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); border-inline-start-width: var(--pf-c-tabs--m-box__item--m-current--first-child__link--before--BorderLeftWidth, var(--pf-c-tabs__link--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); } -:host(.last[box][active]) button::before { +:host(:last-of-type) .box.active #button::before { border-inline-end-width: var(--pf-c-tabs--m-box__item--m-current--last-child__link--before--BorderRightWidth, var(--pf-c-tabs--before--border-width--base, var(--pf-global--BorderWidth--sm, 1px))); } -:host([disabled]) button, -:host([aria-disabled="true"]) button { +:host([disabled]) #button, +:host([aria-disabled="true"]) #button { --pf-c-tabs__link--Color: var(--pf-c-tabs__link--disabled--Color, var(--pf-global--disabled-color--100, #6a6e73)); --pf-c-tabs__link--BackgroundColor: var(--pf-c-tabs__link--disabled--BackgroundColor, var(--pf-global--palette--black-150, #f5f5f5)); --pf-c-tabs__link--before--BorderRightWidth: var(--pf-c-tabs__link--disabled--before--BorderRightWidth, 0); @@ -131,7 +180,7 @@ button:active { display: none !important; } -:host([disabled][border-bottom="false"]) button, -:host([aria-disabled="true"][border-bottom="false"]) button { +:host([disabled][border-bottom="false"]) #button, +:host([aria-disabled="true"][border-bottom="false"]) #button { --pf-c-tabs__link--before--BorderBottomWidth: 0; } diff --git a/elements/pf-tabs/pf-tab.ts b/elements/pf-tabs/pf-tab.ts index a111a445b4..3cc3ff9977 100644 --- a/elements/pf-tabs/pf-tab.ts +++ b/elements/pf-tabs/pf-tab.ts @@ -1,81 +1,159 @@ +import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; +import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { consume } from '@lit/context'; import { observed } from '@patternfly/pfe-core/decorators.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; -import { BaseTab } from './BaseTab.js'; +import { InternalsController } from '@patternfly/pfe-core/controllers/internals-controller.js'; + +import { TabExpandEvent, context, type PfTabsContext } from './context.js'; import styles from './pf-tab.css'; /** * Tab - * * @slot icon * Can contain an `` or `` * @slot * Tab title text - * * @csspart button - button element * @csspart icon - span container for the icon * @csspart text - span container for the title text - * * @cssprop {} --pf-c-tabs--m-box__item--m-current--first-child__link--before--BorderLeftWidth {@default `1px`} * @cssprop {} --pf-c-tabs--m-box__item--m-current--last-child__link--before--BorderRightWidth {@default `1px`} - * * @cssprop {} --pf-c-tabs__link--BackgroundColor {@default `#f0f0f0`} * @cssprop {} --pf-c-tabs__link--disabled--BackgroundColor {@default `#d2d2d2`} - * * @cssprop {} --pf-c-tabs__link--before--BorderTopWidth {@default `1px`} * @cssprop {} --pf-c-tabs__link--before--BorderBottomWidth {@default `1px`} * @cssprop {} --pf-c-tabs__link--before--BorderLeftWidth {@default `0`} * @cssprop {} --pf-c-tabs__link--before--BorderRightWidth {@default `1px`} - * * @cssprop {} --pf-c-tabs__link--disabled--before--BorderRightWidth {@default `1px`} - * * @cssprop {} --pf-c-tabs__link--after--Top {@default `auto`} * @cssprop {} --pf-c-tabs__link--after--Right {@default `0`} * @cssprop {} --pf-c-tabs__link--after--Bottom {@default `0`} * @cssprop {} --pf-c-tabs__link--before--Left {@default `0`} - * * @cssprop {} --pf-c-tabs__link--PaddingTop {@default `1rem`} * @cssprop {} --pf-c-tabs__link--PaddingBottom {@default `1rem`} - * * @cssprop {} --pf-c-tabs__link--disabled--before--BorderBottomWidth {@default `1px`} * @cssprop {} --pf-c-tabs__link--disabled--before--BorderLeftWidth {@default `1px`} - * * @cssprop {} --pf-c-tabs__link--before--BorderTopColor {@default `#d2d2d2`} * @cssprop {} --pf-c-tabs__link--before--BorderRightColor {@default `#d2d2d2`} * @cssprop {} --pf-c-tabs__link--before--BorderBottomColor {@default `#d2d2d2`} * @cssprop {} --pf-c-tabs__link--before--BorderLeftColor {@default `#d2d2d2`} - * * @cssprop {} --pf-c-tabs__link--FontSize {@default `1rem`} * @cssprop {} --pf-c-tabs__link--Color {@default `#6a6e73`} * @cssprop {} --pf-c-tabs__link--OutlineOffset {@default `-0.375rem`} - * * @cssprop {} --pf-c-tabs__link--after--BorderColor {@default `#b8bbbe`} * @cssprop {} --pf-c-tabs__link--after--BorderTopWidth {@default `0`} * @cssprop {} --pf-c-tabs__link--after--BorderRightWidth {@default `0`} * @cssprop {} --pf-c-tabs__link--after--BorderBottomWidth {@default `0`} * @cssprop {} --pf-c-tabs__link--after--BorderLeftWidth {@default `0`} - * * @cssprop {} --pf-c-tabs__item--m-current__link--Color {@default `#151515`} - * * @cssprop {} --pf-c-tabs__item--m-current__link--after--BorderColor {@default `#06c`} * @cssprop {} --pf-c-tabs__item--m-current__link--after--BorderWidth {@default `3px`} - * * @cssprop {} --pf-c-tabs__link--child--MarginRight {@default `1rem`} - * - * @fires { TabExpandEvent } expand - when a tab expands + * @fires {TabExpandEvent} expand - when a tab expands */ @customElement('pf-tab') -export class PfTab extends BaseTab { - static readonly styles = [...BaseTab.styles, styles]; +export class PfTab extends LitElement { + static readonly styles = [styles]; + + @queryAssignedElements({ slot: 'icon', flatten: true }) + private icons!: HTMLElement[]; @observed @property({ reflect: true, type: Boolean }) active = false; @observed @property({ reflect: true, type: Boolean }) disabled = false; + + @consume({ context, subscribe: true }) + @property({ attribute: false }) + private ctx?: PfTabsContext; + + #internals = InternalsController.of(this, { role: 'tab' }); + + override connectedCallback() { + super.connectedCallback(); + this.id ||= getRandomId(this.localName); + this.addEventListener('click', this.#onClick); + this.addEventListener('keydown', this.#onKeydown); + this.addEventListener('focus', this.#onFocus); + } + + override willUpdate() { + const { borderBottom, box, fill, manual, vertical } = this.ctx ?? {}; + this.toggleAttribute('fill', fill); + this.toggleAttribute('manual', manual); + this.toggleAttribute('vertical', vertical); + if (box) { + this.setAttribute('box', box); + } else { + this.removeAttribute('box'); + } + if (borderBottom) { + this.setAttribute('border-bottom', borderBottom); + } else { + this.removeAttribute('border-bottom'); + } + } + + render() { + const { active } = this; + const { box, fill = false, vertical = false } = this.ctx ?? {}; + const light = box === 'light'; + const dark = box === 'dark'; + return html` +
+ + +
+ `; + } + + #onClick() { + if (!this.disabled) { + this.#activate(); + } + } + + #onKeydown(event: KeyboardEvent) { + if (!this.disabled) { + switch (event.key) { + case 'Enter': this.#activate(); + } + } + } + + #onFocus() { + if (!this.ctx?.manual && !this.disabled) { + this.#activate(); + } + } + + #activate() { + return this.dispatchEvent(new TabExpandEvent(this)); + } + + private _activeChanged(old: boolean) { + this.#internals.ariaSelected = String(!!this.active); + if (this.active && !old) { + this.#activate(); + } + } + + private _disabledChanged() { + this.#internals.ariaDisabled = this.disabled ? 'true' : this.ariaDisabled ?? 'false'; + } } declare global { diff --git a/elements/pf-tabs/pf-tabs.css b/elements/pf-tabs/pf-tabs.css index 89573c3ea1..95e5a1aaaf 100644 --- a/elements/pf-tabs/pf-tabs.css +++ b/elements/pf-tabs/pf-tabs.css @@ -1,3 +1,90 @@ +:host { + display: block; +} + +[part="tabs-container"] { + position: relative; + display: flex; + overflow: hidden; +} + +[part="tabs-container"]::before { + position: absolute; + right: 0; + bottom: 0; + left: 0; + border-style: solid; +} + +:host button { + opacity: 1; +} + +:host button:nth-of-type(1) { + margin-inline-end: 0; + translate: 0 0; +} + +:host button:nth-of-type(2) { + margin-inline-start: 0; + translate: 0 0; +} + +[part="tabs"], +[part="panels"] { + display: block; +} + +[part="tabs"] { + scrollbar-width: none; + position: relative; + max-width: 100%; + overflow-x: auto; +} + +[part="tabs-container"]::before, +[part="tabs"]::before, +button::before { + position: absolute; + right: 0; + bottom: 0; + left: 0; + content: ""; + border-style: solid; +} + +[part="tabs"]::before, +button::before { + top: 0; +} + +button, +[part="tabs"]::before { + border: 0; +} + +button { + flex: none; + line-height: 1; + opacity: 0; +} + +button::before { + border-block-start-width: 0; +} + +button:nth-of-type(1) { + translate: -100% 0; +} + +button:nth-of-type(2) { + translate: 100% 0; +} + +button:disabled { + pointer-events: none; +} + [part="tabs-container"] { width: var(--pf-c-tabs--Width, auto); padding-inline-end: var(--pf-c-tabs--inset, 0); @@ -12,16 +99,6 @@ border-inline-start-width: var(--pf-c-tabs--before--BorderLeftWidth, 0); } -/* workaround to disable scroll right button when last tab is aria-disabled */ -:host(:not([vertical])) ::slotted(pf-tab[aria-disabled=true]:last-of-type) { - translate: calc(-1 * var(--pf-c-tabs__link--disabled--before--BorderRightWidth, 1px)) 0; -} - -/* workaround to disable scroll left button when first tab is aria-disabled */ -:host(:not([vertical])) ::slotted(pf-tab[aria-disabled=true]:first-of-type) { - translate: var(--pf-c-tabs__link--disabled--before--BorderRightWidth, 1px) 0; -} - :host([box]) [part="tabs-container"] { --pf-c-tabs__link--BackgroundColor: var(--pf-c-tabs--m-box__link--BackgroundColor, var(--pf-global--BackgroundColor--200, #f0f0f0)); --pf-c-tabs__link--disabled--BackgroundColor: var(--pf-c-tabs--m-box__link--disabled--BackgroundColor, var(--pf-global--disabled-color--200, #d2d2d2)); diff --git a/elements/pf-tabs/pf-tabs.ts b/elements/pf-tabs/pf-tabs.ts index ef79f6c08f..0da24b0fbf 100644 --- a/elements/pf-tabs/pf-tabs.ts +++ b/elements/pf-tabs/pf-tabs.ts @@ -1,49 +1,49 @@ +import { html, LitElement, type PropertyValues } from 'lit'; import { customElement } from 'lit/decorators/custom-element.js'; import { property } from 'lit/decorators/property.js'; +import { query } from 'lit/decorators/query.js'; +import { provide } from '@lit/context'; +import { classMap } from 'lit/directives/class-map.js'; -import { cascades } from '@patternfly/pfe-core/decorators.js'; +import { Logger } from '@patternfly/pfe-core/controllers/logger.js'; +import { OverflowController } from '@patternfly/pfe-core/controllers/overflow-controller.js'; +import { RovingTabindexController } from '@patternfly/pfe-core/controllers/roving-tabindex-controller.js'; +import { TabsAriaController } from '@patternfly/pfe-core/controllers/tabs-aria-controller.js'; -import { BaseTabs } from './BaseTabs.js'; -import { TabExpandEvent } from './BaseTab.js'; import { PfTab } from './pf-tab.js'; import { PfTabPanel } from './pf-tab-panel.js'; +import { getRandomId } from '@patternfly/pfe-core/functions/random.js'; + +import { type PfTabsContext, TabExpandEvent, context } from './context.js'; + +import '@patternfly/elements/pf-icon/pf-icon.js'; + import styles from './pf-tabs.css'; /** * **Tabs** allow users to navigate between views within the same page or context. - * - * @attr {number} active-key - DOM Property: `activeKey` {@default `0`} - * * @csspart container - outer container * @csspart tabs-container - tabs container * @csspart tabs - tablist * @csspart panels - panels - * * @slot tab - Must contain one or more `` * @slot - Must contain one or more `` - * * @cssprop {} --pf-c-tabs--Width {@default `auto`} * @cssprop {} --pf-c-tabs--inset {@default `0`} - * * @cssprop {} --pf-c-tabs--before--BorderColor {@default `#d2d2d2`} * @cssprop {} --pf-c-tabs--before--BorderTopWidth {@default `0`} * @cssprop {} --pf-c-tabs--before--BorderRightWidth {@default `0`} * @cssprop {} --pf-c-tabs--before--BorderBottomWidth {@default `1px`} * @cssprop {} --pf-c-tabs--before---BorderLeftWidth {@default `0`} - * * @cssprop {} --pf-c-tabs--m-vertical--MaxWidth {@default `15.625rem`} - * * @cssprop {} --pf-c-tabs--m-vertical__list--before--BorderColor {@default `#d2d2d2`} * @cssprop {} --pf-c-tabs--m-vertical__list--before--BorderTopWidth {@default `0`} * @cssprop {} --pf-c-tabs--m-vertical__list--before--BorderRightWidth {@default `0`} * @cssprop {} --pf-c-tabs--m-vertical__list--before--BorderBottomWidth {@default `0`} * @cssprop {} --pf-c-tabs--m-vertical__list--before--BorderLeftWidth {@default `1px`} - * * @cssprop {} --pf-c-tabs--m-vertical--m-box--inset {@default `2rem`} - * * @cssprop {} --pf-c-tabs__list--Display {@default `flex`} - * * @cssprop {} --pf-c-tabs__scroll-button--Width {@default `3rem`} * @cssprop {} --pf-c-tabs__scroll-button--Color {@default `#151515`} * @cssprop {} --pf-c-tabs__scroll-button--BackgroundColor {@default `#ffffff`} @@ -51,47 +51,208 @@ import styles from './pf-tabs.css'; * @cssprop {