From dead18d23cf36cafab309ec8a5d670300a65fa53 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Mon, 2 Jun 2025 16:09:49 -0400 Subject: [PATCH 01/18] Fix scroll on reload (#1015) * fix scroll on reload * fix comment --- .../webawesome/docs/assets/scripts/scroll.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/webawesome/docs/assets/scripts/scroll.js b/packages/webawesome/docs/assets/scripts/scroll.js index 8a2bbe113..609c29d5e 100644 --- a/packages/webawesome/docs/assets/scripts/scroll.js +++ b/packages/webawesome/docs/assets/scripts/scroll.js @@ -1,3 +1,19 @@ +import { allDefined } from '/dist/webawesome.js'; + +/** + * Determines how the page was loaded. Possible return values include "reload", "navigate", "back_forward", "prerender", + * and "unknown". + */ +function getNavigationType() { + if (performance.getEntriesByType) { + const navEntries = performance.getEntriesByType('navigation'); + if (navEntries.length > 0) { + return navEntries[0].type; + } + } + return 'unknown'; +} + // Smooth links document.addEventListener('click', event => { const link = event.target.closest('a'); @@ -31,3 +47,26 @@ function updateScrollClass() { window.addEventListener('scroll', updateScrollClass); window.addEventListener('turbo:render', updateScrollClass); updateScrollClass(); + +// Restore scroll position after components are defined +allDefined().then(() => { + const navigationType = getNavigationType(); + const key = `wa-scroll-y-[${location.pathname}]`; + const scrollY = sessionStorage.getItem(key); + + // Only restore when reloading, otherwise clear it + if (navigationType === 'reload' && scrollY) { + window.scrollTo(0, scrollY); + } else { + sessionStorage.removeItem(key); + } + + // After restoring, keep tabs on the page's scroll position for next reload + window.addEventListener( + 'scroll', + () => { + sessionStorage.setItem(key, window.scrollY); + }, + { passive: true }, + ); +}); From 7bdc9a2cc4c5d22b86d5ba7a326fdc4d921e57dd Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Mon, 2 Jun 2025 16:13:24 -0400 Subject: [PATCH 02/18] Add `` (#1012) * remove redundant styles from template * rotate arrow based on placement so borders show correctly when applied * use actual placement, not preferred * add popover * update changelog * update changelog * use for popover * fix arrow border in FF/Safari * update content * add sidebar to plop * add popover --- .../webawesome/docs/_includes/sidebar.njk | 2 + .../docs/docs/components/popover.md | 143 ++++++++ .../docs/docs/resources/changelog.md | 1 + packages/webawesome/scripts/plop/plopfile.js | 6 + .../plop/templates/component/component.hbs | 3 +- .../src/components/popover/popover.css | 91 +++++ .../src/components/popover/popover.test.ts | 9 + .../src/components/popover/popover.ts | 310 ++++++++++++++++++ .../webawesome/src/components/popup/popup.css | 14 +- 9 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 packages/webawesome/docs/docs/components/popover.md create mode 100644 packages/webawesome/src/components/popover/popover.css create mode 100644 packages/webawesome/src/components/popover/popover.test.ts create mode 100644 packages/webawesome/src/components/popover/popover.ts diff --git a/packages/webawesome/docs/_includes/sidebar.njk b/packages/webawesome/docs/_includes/sidebar.njk index 8f6a0d40c..8336b8b52 100644 --- a/packages/webawesome/docs/_includes/sidebar.njk +++ b/packages/webawesome/docs/_includes/sidebar.njk @@ -137,6 +137,7 @@
  • Mutation Observer
  • +
  • Popover
  • Popup
  • Progress Bar
  • Progress Ring
  • @@ -174,6 +175,7 @@
  • Tooltip
  • Tree
  • Tree Item
  • + {# PLOP_NEW_COMPONENT_PLACEHOLDER #} diff --git a/packages/webawesome/docs/docs/components/popover.md b/packages/webawesome/docs/docs/components/popover.md new file mode 100644 index 000000000..b4edbdaa6 --- /dev/null +++ b/packages/webawesome/docs/docs/components/popover.md @@ -0,0 +1,143 @@ +--- +title: Popover +layout: component +--- + +Popovers display interactive content when their anchor element is clicked. Unlike [tooltips](/docs/components/tooltip), popovers can contain links, buttons, and form controls. They appear without an overlay and will close when you click outside or press [[Escape]]. Only one popover can be open at a time. + +```html {.example} + +
    +

    This popover contains interactive content that users can engage with directly.

    + Take Action +
    +
    + +Show popover +``` + +## Examples + +### Assigning an Anchor + +Use `` or ` + + + I'm anchored to a native button. + +``` + +:::warning +Make sure the anchor element exists in the DOM before the popover connects. If it doesn't exist, the popover won't attach and you'll see a console warning. +::: + +### Opening and Closing + +Popovers show when you click their anchor element. You can also control them programmatically by setting the `open` property to `true` or `false`. + +Use `data-popover="close"` on any button inside a popover to close it automatically. + +```html {.example} + +

    The button below has data-popover="close" so clicking it will close the popover.

    + Dismiss +
    + +Show popover +``` + +### Placement + +Use the `placement` attribute to set where the popover appears relative to its anchor. The popover will automatically reposition if there isn't enough space in the preferred location. The default placement is `top`. + +```html {.example} +
    + Top + I'm on the top + + Bottom + I'm on the bottom + + Left + I'm on the left + + Right + I'm on the right +
    +``` + +### Distance + +Use the `distance` attribute to control how far the popover appears from its anchor. + +```html {.example} +
    + Near + I'm very close + + Far + I'm farther away +
    +``` + +### Arrow Size + +Use the `--arrow-size` custom property to change the size of the popover's arrow. Set it to `0` to remove the arrow entirely. + +```html {.example} +
    + Big arrow + I have a big arrow + + No arrow + I don't have an arrow +
    +``` + +### Setting a Maximum Width + +Use the `--max-width` custom property to control the maximum width of the popover. + +```html {.example} +Toggle me + + Popovers will usually grow to be much wider, but this one has a custom max width that forces text to wrap. + +``` + +### Setting Focus + +Use the [`autofocus`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autofocus) global attribute to move focus to a specific form control when the popover opens. + +```html {.example} + +
    + + + Submit + +
    +
    + + + + Feedback + +``` \ No newline at end of file diff --git a/packages/webawesome/docs/docs/resources/changelog.md b/packages/webawesome/docs/docs/resources/changelog.md index d16499060..0c6d661af 100644 --- a/packages/webawesome/docs/docs/resources/changelog.md +++ b/packages/webawesome/docs/docs/resources/changelog.md @@ -31,6 +31,7 @@ During the alpha period, things might break! We take breaking changes very serio - `` => `` - `` => `` - 🚨 BREAKING: removed the `size` attribute from ``; please set the size of child elements on the children directly +- Added a new free component: `` (#2 of 14 per stretch goals) - Added a `min-block-size` to `` to ensure the divider is visible regardless of container height [issue:675] - Fixed a bug in `` that caused radios to uncheck when assigning a numeric value [issue:924] - Fixed `` so dividers properly show between buttons diff --git a/packages/webawesome/scripts/plop/plopfile.js b/packages/webawesome/scripts/plop/plopfile.js index da5664c18..cc63ce8d5 100644 --- a/packages/webawesome/scripts/plop/plopfile.js +++ b/packages/webawesome/scripts/plop/plopfile.js @@ -50,6 +50,12 @@ export default function (plop) { path: '../../docs/docs/components/{{ tagWithoutPrefix tag }}.md', templateFile: 'templates/component/docs.hbs', }, + { + type: 'modify', + path: '../../docs/_includes/sidebar.njk', + pattern: /\{# PLOP_NEW_COMPONENT_PLACEHOLDER #\}/, + template: `
  • {{ tagToTitle tag }}
  • \n {# PLOP_NEW_COMPONENT_PLACEHOLDER #}`, + }, ], }); } diff --git a/packages/webawesome/scripts/plop/templates/component/component.hbs b/packages/webawesome/scripts/plop/templates/component/component.hbs index d84d77930..1f141f54b 100644 --- a/packages/webawesome/scripts/plop/templates/component/component.hbs +++ b/packages/webawesome/scripts/plop/templates/component/component.hbs @@ -2,7 +2,6 @@ import { html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { watch } from '../../internal/watch.js'; import WebAwesomeElement from '../../internal/webawesome-element.js'; -import componentStyles from '../../styles/component/host.css'; import styles from './{{ tagWithoutPrefix tag }}.css'; /** @@ -22,7 +21,7 @@ import styles from './{{ tagWithoutPrefix tag }}.css'; */ @customElement("{{ tag }}") export default class {{ properCase tag }} extends WebAwesomeElement { - static shadowStyle = [componentStyles, styles]; + static shadowStyle = styles; /** An example attribute. */ @property() attr = 'example'; diff --git a/packages/webawesome/src/components/popover/popover.css b/packages/webawesome/src/components/popover/popover.css new file mode 100644 index 000000000..80cf129a3 --- /dev/null +++ b/packages/webawesome/src/components/popover/popover.css @@ -0,0 +1,91 @@ +:host { + --arrow-size: 0.375rem; + --max-width: 25rem; + --show-duration: 100ms; + --hide-duration: 100ms; + + /* Internal calculated properties */ + --arrow-diagonal-size: calc((var(--arrow-size) * sin(45deg))); + + display: contents; + + /** Defaults for inherited CSS properties */ + font-size: var(--wa-popover-font-size); + line-height: var(--wa-popover-line-height); + text-align: start; + white-space: normal; +} + +/* The native dialog element */ +.dialog { + display: none; + position: fixed; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: none; + background: transparent; + overflow: visible; + pointer-events: none; + + &:focus { + outline: none; + } + + &[open] { + display: block; + } +} + +/* The element */ +.popover { + --arrow-size: inherit; + --show-duration: inherit; + --hide-duration: inherit; + + pointer-events: auto; + + &::part(arrow) { + background-color: var(--wa-color-surface-default); + border-top: none; + border-left: none; + border-bottom: solid var(--wa-panel-border-width) var(--wa-color-surface-border); + border-right: solid var(--wa-panel-border-width) var(--wa-color-surface-border); + box-shadow: none; + } +} + +.popover[placement^='top']::part(popup) { + transform-origin: bottom; +} + +.popover[placement^='bottom']::part(popup) { + transform-origin: top; +} + +.popover[placement^='left']::part(popup) { + transform-origin: right; +} + +.popover[placement^='right']::part(popup) { + transform-origin: left; +} + +/* Body */ +.body { + display: flex; + flex-direction: column; + width: max-content; + max-width: var(--max-width); + padding: var(--wa-space); + background-color: var(--wa-color-surface-default); + border: var(--wa-panel-border-width) solid var(--wa-color-surface-border); + border-radius: var(--wa-panel-border-radius); + border-style: var(--wa-panel-border-style); + box-shadow: var(--wa-shadow-s); + color: var(--wa-color-text-normal); + user-select: none; + -webkit-user-select: none; +} diff --git a/packages/webawesome/src/components/popover/popover.test.ts b/packages/webawesome/src/components/popover/popover.test.ts new file mode 100644 index 000000000..eccb8adb9 --- /dev/null +++ b/packages/webawesome/src/components/popover/popover.test.ts @@ -0,0 +1,9 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/packages/webawesome/src/components/popover/popover.ts b/packages/webawesome/src/components/popover/popover.ts new file mode 100644 index 000000000..1b769b8f4 --- /dev/null +++ b/packages/webawesome/src/components/popover/popover.ts @@ -0,0 +1,310 @@ +import { html } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { WaAfterHideEvent } from '../../events/after-hide.js'; +import { WaAfterShowEvent } from '../../events/after-show.js'; +import { WaHideEvent } from '../../events/hide.js'; +import { WaShowEvent } from '../../events/show.js'; +import { animateWithClass } from '../../internal/animate.js'; +import { waitForEvent } from '../../internal/event.js'; +import { uniqueId } from '../../internal/math.js'; +import { watch } from '../../internal/watch.js'; +import WebAwesomeElement from '../../internal/webawesome-element.js'; +import WaPopup from '../popup/popup.js'; +import styles from './popover.css'; + +const openPopovers = new Set(); + +/** + * @summary Popovers display contextual content and interactive elements in a floating panel. + * @documentation https://backers.webawesome.com/docs/components/popover + * @status stable + * @since 3.0 + * + * @dependency wa-popup + * + * @slot - The popover's content. Interactive elements such as buttons and links are supported. + * + * @event wa-show - Emitted when the popover begins to show. Canceling this event will stop the popover from showing. + * @event wa-after-show - Emitted after the popover has shown and all animations are complete. + * @event wa-hide - Emitted when the popover begins to hide. Canceling this event will stop the popover from hiding. + * @event wa-after-hide - Emitted after the popover has hidden and all animations are complete. + * + * @csspart dialog - The native dialog element that contains the popover content. + * @csspart body - The popover's body where its content is rendered. + * @csspart popup - The internal `` element that positions the popover. + * @csspart popup__popup - The popup's exported `popup` part. Use this to target the popover's popup container. + * @csspart popup__arrow - The popup's exported `arrow` part. Use this to target the popover's arrow. + * + * @cssproperty [--arrow-size=0.375rem] - The size of the tiny arrow that points to the popover (set to zero to remove). + * @cssproperty [--max-width=25rem] - The maximum width of the popover's body content. + * @cssproperty [--show-duration=100ms] - The speed of the show animation. + * @cssproperty [--hide-duration=100ms] - The speed of the hide animation. + */ +@customElement('wa-popover') +export default class WaPopover extends WebAwesomeElement { + static shadowStyle = styles; + static dependencies = { 'wa-popup': WaPopup }; + + @query('dialog') dialog: HTMLDialogElement; + @query('.body') body: HTMLElement; + @query('wa-popup') popup: WaPopup; + + @state() anchor: null | Element = null; + + /** + * The preferred placement of the popover. Note that the actual placement may vary as needed to keep the popover + * inside of the viewport. + */ + @property() placement: + | 'top' + | 'top-start' + | 'top-end' + | 'right' + | 'right-start' + | 'right-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end' = 'top'; + + /** Shows or hides the popover. */ + @property({ type: Boolean, reflect: true }) open = false; + + /** The distance in pixels from which to offset the popover away from its target. */ + @property({ type: Number }) distance = 8; + + /** The distance in pixels from which to offset the popover along its target. */ + @property({ type: Number }) skidding = 0; + + /** The ID of the popover's anchor element. This must be an interactive/focusable element such as a button. */ + @property() for: string | null = null; + + private eventController = new AbortController(); + + connectedCallback() { + super.connectedCallback(); + + // If the user doesn't give us an id, generate one. + if (!this.id) { + this.id = uniqueId('wa-popover-'); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + + // Cleanup events in case the popover is removed while open + document.removeEventListener('keydown', this.handleDocumentKeyDown); + this.eventController.abort(); + } + + firstUpdated() { + // If the popover is visible on init, update its position + if (this.open) { + this.dialog.show(); + this.popup.active = true; + this.popup.reposition(); + } + } + + private handleAnchorClick = () => { + // Clicks on the anchor should toggle the popover + this.open = !this.open; + }; + + private handleBodyClick = (event: PointerEvent) => { + const target = event.target as HTMLElement; + const button = target.closest('[data-popover="close"]'); + + // Watch for [data-popover="close"] clicks + if (button) { + event.stopPropagation(); + this.open = false; + } + }; + + private handleDocumentKeyDown = (event: KeyboardEvent) => { + // Hide the popover when escape is pressed + if (event.key === 'Escape') { + event.preventDefault(); + this.open = false; + if (this.anchor && typeof (this.anchor as any).focus === 'function') { + (this.anchor as any).focus(); + } + } + }; + + private handleDocumentClick = (event: PointerEvent) => { + const target = event.target as HTMLElement; + + // Ignore clicks on the anchor so it will be closed by the anchor's click handler + if (this.anchor && event.composedPath().includes(this.anchor)) { + return; + } + + // Detect when clicks occur outside the popover + if (target.closest('wa-popover') !== this) { + this.open = false; + } + }; + + @watch('open', { waitUntilFirstUpdate: true }) + async handleOpenChange() { + if (this.open) { + // Show + const waShowEvent = new WaShowEvent(); + this.dispatchEvent(waShowEvent); + if (waShowEvent.defaultPrevented) { + this.open = false; + return; + } + + // Close other popovers that are open + openPopovers.forEach(popover => (popover.open = false)); + + document.addEventListener('keydown', this.handleDocumentKeyDown, { signal: this.eventController.signal }); + document.addEventListener('click', this.handleDocumentClick, { signal: this.eventController.signal }); + + // Show the dialog non-modally + this.dialog.show(); + this.popup.active = true; + openPopovers.add(this); + + // Autofocus the first element with the autofocus attribute + requestAnimationFrame(() => { + const elementToFocus = this.querySelector('[autofocus]'); + if (elementToFocus && typeof elementToFocus.focus === 'function') { + elementToFocus.focus(); + } else { + // Fall back to setting focus on the dialog + this.dialog.focus(); + } + }); + + await animateWithClass(this.popup.popup, 'show-with-scale'); + this.popup.reposition(); + + this.dispatchEvent(new WaAfterShowEvent()); + } else { + // Hide + const waHideEvent = new WaHideEvent(); + this.dispatchEvent(waHideEvent); + if (waHideEvent.defaultPrevented) { + this.open = true; + return; + } + + document.removeEventListener('keydown', this.handleDocumentKeyDown); + document.removeEventListener('click', this.handleDocumentClick); + + openPopovers.delete(this); + + await animateWithClass(this.popup.popup, 'hide-with-scale'); + this.popup.active = false; + this.dialog.close(); + + this.dispatchEvent(new WaAfterHideEvent()); + } + } + + @watch('for') + handleForChange() { + const rootNode = this.getRootNode() as Document | ShadowRoot | null; + + if (!rootNode) { + return; + } + + const newAnchor = this.for ? rootNode.querySelector(`#${this.for}`) : null; + const oldAnchor = this.anchor; + + if (newAnchor === oldAnchor) { + return; + } + + const { signal } = this.eventController; + + if (newAnchor) { + newAnchor.addEventListener('click', this.handleAnchorClick, { signal }); + } + + if (oldAnchor) { + oldAnchor.removeEventListener('click', this.handleAnchorClick); + } + + this.anchor = newAnchor; + + if (this.for && !newAnchor) { + console.warn( + `A popover was assigned to an element with an ID of "${this.for}" but the element could not be found.`, + this, + ); + } + } + + @watch(['distance', 'placement', 'skidding']) + async handleOptionsChange() { + if (this.hasUpdated) { + await this.updateComplete; + this.popup.reposition(); + } + } + + /** Shows the popover. */ + async show() { + if (this.open) { + return undefined; + } + + this.open = true; + return waitForEvent(this, 'wa-after-show'); + } + + /** Hides the popover. */ + async hide() { + if (!this.open) { + return undefined; + } + + this.open = false; + return waitForEvent(this, 'wa-after-hide'); + } + + render() { + return html` + + +
    + +
    +
    +
    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'wa-popover': WaPopover; + } +} diff --git a/packages/webawesome/src/components/popup/popup.css b/packages/webawesome/src/components/popup/popup.css index cdb08e228..7db983610 100644 --- a/packages/webawesome/src/components/popup/popup.css +++ b/packages/webawesome/src/components/popup/popup.css @@ -48,7 +48,19 @@ height: calc(var(--arrow-size-diagonal) * 2); rotate: 45deg; background: var(--arrow-color); - z-index: -1; + z-index: 3; +} + +:host([data-current-placement~='left']) .arrow { + rotate: -45deg; +} + +:host([data-current-placement~='right']) .arrow { + rotate: 135deg; +} + +:host([data-current-placement~='bottom']) .arrow { + rotate: 225deg; } /* Hover bridge */ From afb2082c79ebf2077ca4dce4d6b334e4b3d7753b Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Tue, 3 Jun 2025 15:08:52 -0400 Subject: [PATCH 03/18] add resize-vertical (#1018) --- packages/webawesome/src/styles/native.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/webawesome/src/styles/native.css b/packages/webawesome/src/styles/native.css index 67fa1520a..48db81982 100644 --- a/packages/webawesome/src/styles/native.css +++ b/packages/webawesome/src/styles/native.css @@ -924,6 +924,10 @@ } } + textarea { + resize: vertical; + } + input.wa-pill, textarea.wa-pill { border-radius: var(--wa-border-radius-pill) !important; From 6b2a081fa09dfe5a9fa590f912abf9ba3911b32d Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Tue, 3 Jun 2025 15:09:34 -0400 Subject: [PATCH 04/18] Improve rating's default a11y (#1019) * add regular star * support dual icons; improve default contrast * support dual icons; improve contrast * update docs * Update packages/webawesome/src/components/rating/rating.css Co-authored-by: Lindsay M <126139086+lindsaym-fa@users.noreply.github.com> --------- Co-authored-by: Lindsay M <126139086+lindsaym-fa@users.noreply.github.com> --- .../webawesome/docs/docs/components/rating.md | 4 ++-- .../webawesome/docs/docs/resources/changelog.md | 1 + .../src/components/icon/library.system.ts | 1 + .../webawesome/src/components/rating/rating.css | 2 +- .../webawesome/src/components/rating/rating.ts | 15 ++++++++++----- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/webawesome/docs/docs/components/rating.md b/packages/webawesome/docs/docs/components/rating.md index 682fc1b4c..26631de31 100644 --- a/packages/webawesome/docs/docs/components/rating.md +++ b/packages/webawesome/docs/docs/components/rating.md @@ -131,7 +131,7 @@ You can provide custom icons by passing a function to the `getSymbol` property. ### Value-based Icons -You can also use the `getSymbol` property to render different icons based on value. +You can also use the `getSymbol` property to render different icons based on value and/or whether the icon is currently selected. ```html {.example} @@ -142,7 +142,7 @@ You can also use the `getSymbol` property to render different icons based on val await customElements.whenDefined("wa-rating") await rating.updateComplete - rating.getSymbol = value => { + rating.getSymbol = (value, isSelected) => { const icons = ['face-angry', 'face-frown', 'face-meh', 'face-smile', 'face-laugh']; return ``; }; diff --git a/packages/webawesome/docs/docs/resources/changelog.md b/packages/webawesome/docs/docs/resources/changelog.md index 0c6d661af..54ec19beb 100644 --- a/packages/webawesome/docs/docs/resources/changelog.md +++ b/packages/webawesome/docs/docs/resources/changelog.md @@ -39,6 +39,7 @@ During the alpha period, things might break! We take breaking changes very serio - Fixed a bug in `` and native `
    ` styles that made the summary hard to click [issue:684] - Improved CSS utilities and Native Styles to use [CSS layers](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) for easier end user customization (no more specificity conflicts — your CSS wins!) - Improved native ` + ${this.localize.number(this.zoom, { style: 'percent', maximumFractionDigits: 1 })} + + + ` + : ''} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'wa-zoomable-frame': WaZoomableFrame; + } +} diff --git a/packages/webawesome/src/styles/native.css b/packages/webawesome/src/styles/native.css index 4d0e14888..c2205fbfe 100644 --- a/packages/webawesome/src/styles/native.css +++ b/packages/webawesome/src/styles/native.css @@ -51,8 +51,7 @@ table, ul, video, - wa-callout, - wa-viewport-demo { + wa-callout { &:has(+ *) { margin: 0 0 var(--wa-space-xl) 0; } diff --git a/packages/webawesome/src/translations/ar.ts b/packages/webawesome/src/translations/ar.ts index 9b7ff5005..6adc2a0be 100644 --- a/packages/webawesome/src/translations/ar.ts +++ b/packages/webawesome/src/translations/ar.ts @@ -35,6 +35,8 @@ const translation: Translation = { showPassword: 'عرض كلمة المرور', slideNum: slide => `شريحة ${slide}`, toggleColorFormat: 'تغيير صيغة عرض اللون', + zoomIn: 'تكبير', + zoomOut: 'تصغير', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/cs.ts b/packages/webawesome/src/translations/cs.ts index 3532ee711..d8214e363 100644 --- a/packages/webawesome/src/translations/cs.ts +++ b/packages/webawesome/src/translations/cs.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Zobrazit heslo', slideNum: slide => `Slide ${slide}`, toggleColorFormat: 'Přepnout formát barvy', + zoomIn: 'Přiblížit', + zoomOut: 'Oddálit', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/da.ts b/packages/webawesome/src/translations/da.ts index a0c5894a0..b3a2e9d86 100644 --- a/packages/webawesome/src/translations/da.ts +++ b/packages/webawesome/src/translations/da.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Vis adgangskode', slideNum: slide => `Slide ${slide}`, toggleColorFormat: 'Skift farveformat', + zoomIn: 'Zoom ind', + zoomOut: 'Zoom ud', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/de.ts b/packages/webawesome/src/translations/de.ts index b73a18bfc..c876db206 100644 --- a/packages/webawesome/src/translations/de.ts +++ b/packages/webawesome/src/translations/de.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Passwort anzeigen', slideNum: slide => `Folie ${slide}`, toggleColorFormat: 'Farbformat umschalten', + zoomIn: 'Hineinzoomen', + zoomOut: 'Herauszoomen', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/en.ts b/packages/webawesome/src/translations/en.ts index 5c1d4e437..5c5601faa 100644 --- a/packages/webawesome/src/translations/en.ts +++ b/packages/webawesome/src/translations/en.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Show password', slideNum: slide => `Slide ${slide}`, toggleColorFormat: 'Toggle color format', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/es.ts b/packages/webawesome/src/translations/es.ts index 930519dbf..fa740c1ce 100644 --- a/packages/webawesome/src/translations/es.ts +++ b/packages/webawesome/src/translations/es.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Mostrar contraseña', slideNum: slide => `Diapositiva ${slide}`, toggleColorFormat: 'Alternar formato de color', + zoomIn: 'Acercar', + zoomOut: 'Alejar', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/fa.ts b/packages/webawesome/src/translations/fa.ts index ee3d29bc5..aa3ab9fdd 100644 --- a/packages/webawesome/src/translations/fa.ts +++ b/packages/webawesome/src/translations/fa.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'نمایش رمز', slideNum: slide => `اسلاید ${slide}`, toggleColorFormat: 'تغییر قالب رنگ', + zoomIn: 'بزرگ‌نمایی', + zoomOut: 'کوچک‌نمایی', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/fi.ts b/packages/webawesome/src/translations/fi.ts index ba4ffec62..14210828f 100644 --- a/packages/webawesome/src/translations/fi.ts +++ b/packages/webawesome/src/translations/fi.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Näytä salasana', slideNum: slide => `Dia ${slide}`, toggleColorFormat: 'Vaihda väriformaattia', + zoomIn: 'Lähennä', + zoomOut: 'Loitonna', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/fr.ts b/packages/webawesome/src/translations/fr.ts index a821bf993..a6d9bb12c 100644 --- a/packages/webawesome/src/translations/fr.ts +++ b/packages/webawesome/src/translations/fr.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Montrer le mot de passe', slideNum: slide => `Diapositive ${slide}`, toggleColorFormat: 'Changer le format de couleur', + zoomIn: 'Zoomer', + zoomOut: 'Dézoomer', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/he.ts b/packages/webawesome/src/translations/he.ts index a92b5f119..ea564ba2a 100644 --- a/packages/webawesome/src/translations/he.ts +++ b/packages/webawesome/src/translations/he.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'הראה סיסמה', slideNum: slide => `שקופית ${slide}`, toggleColorFormat: 'החלף פורמט צבע', + zoomIn: 'התקרב', + zoomOut: 'התרחק', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/hr.ts b/packages/webawesome/src/translations/hr.ts index b66c2f447..6f2dcc4d6 100644 --- a/packages/webawesome/src/translations/hr.ts +++ b/packages/webawesome/src/translations/hr.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Pokaži lozinku', slideNum: slide => `Slajd ${slide}`, toggleColorFormat: 'Zamijeni format boje', + zoomIn: 'Povećaj', + zoomOut: 'Smanji', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/hu.ts b/packages/webawesome/src/translations/hu.ts index f1203c838..01d21071a 100644 --- a/packages/webawesome/src/translations/hu.ts +++ b/packages/webawesome/src/translations/hu.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Jelszó megjelenítése', slideNum: slide => `${slide}. dia`, toggleColorFormat: 'Színformátum változtatása', + zoomIn: 'Nagyítás', + zoomOut: 'Kicsinyítés', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/id.ts b/packages/webawesome/src/translations/id.ts index 876a474ce..020bb3098 100644 --- a/packages/webawesome/src/translations/id.ts +++ b/packages/webawesome/src/translations/id.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Tampilkan sandi', slideNum: slide => `Slide ${slide}`, toggleColorFormat: 'Beralih format warna', + zoomIn: 'Perbesar', + zoomOut: 'Perkecil', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/it.ts b/packages/webawesome/src/translations/it.ts index a5712e653..330665423 100644 --- a/packages/webawesome/src/translations/it.ts +++ b/packages/webawesome/src/translations/it.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Mostra password', slideNum: slide => `Diapositiva ${slide}`, toggleColorFormat: 'Cambia formato colore', + zoomIn: 'Ingrandire', + zoomOut: 'Rimpicciolire', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/ja.ts b/packages/webawesome/src/translations/ja.ts index 0fb8db111..582fd91da 100644 --- a/packages/webawesome/src/translations/ja.ts +++ b/packages/webawesome/src/translations/ja.ts @@ -32,6 +32,8 @@ const translation: Translation = { showPassword: 'パスワードを表示', slideNum: slide => `スライド ${slide}`, toggleColorFormat: '色のフォーマットを切り替える', + zoomIn: 'ズームイン', + zoomOut: 'ズームアウト', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/nb.ts b/packages/webawesome/src/translations/nb.ts index 7f3d6a510..edd48f4c2 100644 --- a/packages/webawesome/src/translations/nb.ts +++ b/packages/webawesome/src/translations/nb.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Vis passord', slideNum: slide => `Visning ${slide}`, toggleColorFormat: 'Bytt fargeformat', + zoomIn: 'Zoom inn', + zoomOut: 'Zoom ut', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/nl.ts b/packages/webawesome/src/translations/nl.ts index 2338c1f2a..7a0c07ca6 100644 --- a/packages/webawesome/src/translations/nl.ts +++ b/packages/webawesome/src/translations/nl.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Laat wachtwoord zien', slideNum: slide => `Schuif ${slide}`, toggleColorFormat: 'Wissel kleurnotatie', + zoomIn: 'Inzoomen', + zoomOut: 'Uitzoomen', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/nn.ts b/packages/webawesome/src/translations/nn.ts index 74cae3997..cf48c6b28 100644 --- a/packages/webawesome/src/translations/nn.ts +++ b/packages/webawesome/src/translations/nn.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Vis passord', slideNum: slide => `Visning ${slide}`, toggleColorFormat: 'Byt fargeformat', + zoomIn: 'Zoom inn', + zoomOut: 'Zoom ut', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/pl.ts b/packages/webawesome/src/translations/pl.ts index 8bf37ad2c..602d09296 100644 --- a/packages/webawesome/src/translations/pl.ts +++ b/packages/webawesome/src/translations/pl.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Pokaż hasło', slideNum: slide => `Slajd ${slide}`, toggleColorFormat: 'Przełącz format', + zoomIn: 'Powiększ', + zoomOut: 'Pomniejsz', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/pt.ts b/packages/webawesome/src/translations/pt.ts index 7b43fec58..6261d060e 100644 --- a/packages/webawesome/src/translations/pt.ts +++ b/packages/webawesome/src/translations/pt.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Mostrar senha', slideNum: slide => `Slide ${slide}`, toggleColorFormat: 'Trocar o formato de cor', + zoomIn: 'Aumentar zoom', + zoomOut: 'Diminuir zoom', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/ru.ts b/packages/webawesome/src/translations/ru.ts index 5a49d74fb..57a31f5e0 100644 --- a/packages/webawesome/src/translations/ru.ts +++ b/packages/webawesome/src/translations/ru.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Показать пароль', slideNum: slide => `Слайд ${slide}`, toggleColorFormat: 'Переключить цветовую модель', + zoomIn: 'Увеличить', + zoomOut: 'Уменьшить', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/sl.ts b/packages/webawesome/src/translations/sl.ts index d0af3e4a7..e68eddfde 100644 --- a/packages/webawesome/src/translations/sl.ts +++ b/packages/webawesome/src/translations/sl.ts @@ -35,6 +35,8 @@ const translation: Translation = { showPassword: 'Prikaži geslo', slideNum: slide => `Diapozitiv ${slide}`, toggleColorFormat: 'Preklopi format barve', + zoomIn: 'Povečaj', + zoomOut: 'Pomanjšaj', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/sv.ts b/packages/webawesome/src/translations/sv.ts index 8180e1102..38a4e6ff5 100644 --- a/packages/webawesome/src/translations/sv.ts +++ b/packages/webawesome/src/translations/sv.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Visa lösenord', slideNum: slide => `Bild ${slide}`, toggleColorFormat: 'Växla färgformat', + zoomIn: 'Zooma in', + zoomOut: 'Zooma ut', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/tr.ts b/packages/webawesome/src/translations/tr.ts index 8b2e938cc..a850580a7 100644 --- a/packages/webawesome/src/translations/tr.ts +++ b/packages/webawesome/src/translations/tr.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: 'Şifreyi göster', slideNum: slide => `Slayt ${slide}`, toggleColorFormat: 'Renk biçimini değiştir', + zoomIn: 'Yakınlaştır', + zoomOut: 'Uzaklaştır', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/uk.ts b/packages/webawesome/src/translations/uk.ts index c6fe0a88f..ce10a29ba 100644 --- a/packages/webawesome/src/translations/uk.ts +++ b/packages/webawesome/src/translations/uk.ts @@ -35,6 +35,8 @@ const translation: Translation = { showPassword: 'Показати пароль', slideNum: slide => `Слайд ${slide}`, toggleColorFormat: 'Переключити кольорову модель', + zoomIn: 'Збільшити', + zoomOut: 'Зменшити', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/zh-cn.ts b/packages/webawesome/src/translations/zh-cn.ts index 9bea0616c..7f741545f 100644 --- a/packages/webawesome/src/translations/zh-cn.ts +++ b/packages/webawesome/src/translations/zh-cn.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: '显示密码', slideNum: slide => `幻灯片 ${slide}`, toggleColorFormat: '切换颜色模式', + zoomIn: '放大', + zoomOut: '缩小', }; registerTranslation(translation); diff --git a/packages/webawesome/src/translations/zh-tw.ts b/packages/webawesome/src/translations/zh-tw.ts index 54e61014f..2c481d96a 100644 --- a/packages/webawesome/src/translations/zh-tw.ts +++ b/packages/webawesome/src/translations/zh-tw.ts @@ -33,6 +33,8 @@ const translation: Translation = { showPassword: '顯示密碼', slideNum: slide => `幻燈片 ${slide}`, toggleColorFormat: '切換顏色格式', + zoomIn: '放大', + zoomOut: '縮小', }; registerTranslation(translation); diff --git a/packages/webawesome/src/utilities/localize.ts b/packages/webawesome/src/utilities/localize.ts index cc37d910c..35346a19f 100644 --- a/packages/webawesome/src/utilities/localize.ts +++ b/packages/webawesome/src/utilities/localize.ts @@ -4,10 +4,10 @@ import en from '../translations/en.js'; // Register English as the default/fallb // Extend the controller and apply our own translation interface for better typings export class LocalizeController extends DefaultLocalizationController { - // Technicallly '../translations/en.js' is supposed to work via side-effects. However, by some mystery sometimes the - // translations don't get bundled as expected resulting in `no translation found` errors. - // This is basically some extra assurance that our translations get registered prior to our localizer connecting in a component - // and we don't rely on implicit import ordering. + // Technically '../translations/en.js' is supposed to work via side-effects. However, by some mystery sometimes the + // translations don't get bundled as expected resulting in `no translation found` errors. This is basically some extra + // assurance that our translations get registered prior to our localizer connecting in a component and we don't rely + // on implicit import ordering. static { registerTranslation(en); } @@ -44,4 +44,6 @@ export interface Translation extends DefaultTranslation { showPassword: string; slideNum: (slide: number) => string; toggleColorFormat: string; + zoomIn: string; + zoomOut: string; } From c162983ca287320a4b0d2facef75651ccaaa6296 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Thu, 5 Jun 2025 18:03:51 -0400 Subject: [PATCH 16/18] add hover queries (#1035) --- .../docs/assets/styles/code-examples.css | 10 ++++--- .../docs/assets/styles/copy-code.css | 6 +++-- .../webawesome/docs/assets/styles/docs.css | 6 +++-- .../docs/docs/resources/changelog.md | 22 +++++++++------- .../animated-image/animated-image.css | 6 +++-- .../breadcrumb-item/breadcrumb-item.css | 6 +++-- .../components/button-group/button-group.css | 8 +++--- .../src/components/button/button.css | 10 ++++--- .../components/copy-button/copy-button.css | 8 +++++- .../webawesome/src/components/input/input.css | 6 +++-- .../src/components/option/option.css | 8 +++--- .../webawesome/src/components/radio/radio.css | 6 +++-- .../src/components/select/select.css | 6 +++-- .../webawesome/src/components/tab/tab.css | 6 +++-- .../webawesome/src/components/tag/tag.css | 6 +++-- packages/webawesome/src/styles/native.css | 12 +++++---- .../src/styles/themes/matter/overrides.css | 24 ++++++++++------- .../src/styles/themes/playful/dimension.css | 26 +++++++++++-------- .../webawesome/src/styles/utilities/text.css | 18 ++++++++----- 19 files changed, 125 insertions(+), 75 deletions(-) diff --git a/packages/webawesome/docs/assets/styles/code-examples.css b/packages/webawesome/docs/assets/styles/code-examples.css index c945ae45c..5fd30af62 100644 --- a/packages/webawesome/docs/assets/styles/code-examples.css +++ b/packages/webawesome/docs/assets/styles/code-examples.css @@ -116,10 +116,12 @@ padding: 0.5rem; cursor: pointer; - &:hover { - border-left: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet) !important; /* TODO - remove after native styles refactor */ - background: var(--wa-color-surface-default) !important; /* TODO - remove after native styles refactor */ - color: var(--wa-color-text-quiet) !important; /* TODO - remove after native styles refactor */ + @media (hover: hover) { + &:hover { + border-left: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet) !important; /* TODO - remove after native styles refactor */ + background: var(--wa-color-surface-default) !important; /* TODO - remove after native styles refactor */ + color: var(--wa-color-text-quiet) !important; /* TODO - remove after native styles refactor */ + } } &:first-of-type { diff --git a/packages/webawesome/docs/assets/styles/copy-code.css b/packages/webawesome/docs/assets/styles/copy-code.css index f65887b64..a79379669 100644 --- a/packages/webawesome/docs/assets/styles/copy-code.css +++ b/packages/webawesome/docs/assets/styles/copy-code.css @@ -9,8 +9,10 @@ wa-copy-button.copy-button { border-radius: var(--wa-border-radius-m); padding: 0.25rem; - &:hover { - color: white; + @media (hover: hover) { + &:hover { + color: white; + } } &:focus-visible { diff --git a/packages/webawesome/docs/assets/styles/docs.css b/packages/webawesome/docs/assets/styles/docs.css index 3dbf19a0d..126163490 100644 --- a/packages/webawesome/docs/assets/styles/docs.css +++ b/packages/webawesome/docs/assets/styles/docs.css @@ -495,8 +495,10 @@ table.colors { tbody { tr { border: none; - &:hover { - background: transparent; + @media (hover: hover) { + &:hover { + background: transparent; + } } } diff --git a/packages/webawesome/docs/docs/resources/changelog.md b/packages/webawesome/docs/docs/resources/changelog.md index f62d5da9b..77ce2f1f0 100644 --- a/packages/webawesome/docs/docs/resources/changelog.md +++ b/packages/webawesome/docs/docs/resources/changelog.md @@ -47,6 +47,7 @@ During the alpha period, things might break! We take breaking changes very serio - Added `--tag-max-size` to `` when using `multiple` - Added support for `data-dialog="open "` to `` - Added support for `data-drawer="open "` to `` +- Added `@media (hover: hover)` to component hover styles to prevent sticky hover states - Fixed a bug in `` that caused radios to uncheck when assigning a numeric value [issue:924] - Fixed `` so dividers properly show between buttons - Fixed the tooltip position in `` when using RTL @@ -61,8 +62,8 @@ During the alpha period, things might break! We take breaking changes very serio ## 3.0.0-alpha.13 - 🚨 BREAKING: Renamed `` to `` and improved compatibility for non-image content -- 🚨 BREAKING: Added slot detection to `` and `` so you don't need to specify `with-header` and `with-footer`; headers are on by default now, but you can use the `without-header` attribute to turn them off -- 🚨 BREAKING: Renamed the `image` slot to `media` for a more appropriate naming convention +- 🚨 BREAKING: Added slot detection to `` and `` so you don't need to specify `with-header` and `with-footer`; headers are on by default now, but you can use the `without-header` attribute to turn them off +- 🚨 BREAKING: Renamed the `image` slot to `media` for a more appropriate naming convention - Added [a theme builder](/docs/themes/edit/) to create your own themes - Added a new Blog & News pattern category - Added a new free component: `` (#1 of 14 per stretch goals) @@ -124,7 +125,7 @@ During the alpha period, things might break! We take breaking changes very serio ### Design Tokens - Added `--wa-color-[hue]` tokens with the "core" color of each scale, regardless of which tint it lives on. -You can find them in the first column of each color palette. + You can find them in the first column of each color palette. ### Themes @@ -149,20 +150,21 @@ You can find them in the first column of each color palette. - Fixed an incorrect CSS value in the expand icon - Fixed a bug that prevented the description from being read by screen readers -#### `` +#### `` - `label` attribute to override the generated label (useful for rich content) - `defaultLabel` property - Dropped `getTextLabel()` method (if you need dynamic labels, just set the `label` attribute dynamically) - Dropped `base` part for easier styling. CSS can now be applied directly to the element itself. -#### `` +#### `` - `label` attribute to override the generated label (useful for rich content) - `defaultLabel` property - Dropped `getTextLabel()` method (if you need dynamic labels, just set the `label` attribute dynamically) #### `` + - Fixed a bug where child elements did not have correct rounding when headers and footers were absent. - Re-introduced `--border-color` so that the card itself can have a different border color than its inner borders. - Fixed a bug that prevented slots from showing automatically without `with-` attributes @@ -348,12 +350,12 @@ Here's a list of some of the things that have changed since Shoelace v2. For que - Removed `inline` from `` - Removed `getFormControls()` since we now use Form Associated Custom Elements and can reliably access Web Awesome Elements via `formElement.elements`. - Removed `valueAsDate` from ``; use the following to mimic native behaviors: - setter: `waInput.value = new Date().toLocaleDateString()` - getter: `new Date(waInput.value)` + setter: `waInput.value = new Date().toLocaleDateString()` + getter: `new Date(waInput.value)` - Removed `valueAsNumber` from ``; use the following to mimic native behaviors: - setter: `waInput.value = 5.toString()` - getter: `Number(waInput.value)` + setter: `waInput.value = 5.toString()` + getter: `Number(waInput.value)` Did we miss something? [Let us know!](https://github.com/shoelace-style/webawesome-alpha/discussions) -Are you coming from Shoelace? [The 2.x changelog can be found here.](https://shoelace.style/resources/changelog/) \ No newline at end of file +Are you coming from Shoelace? [The 2.x changelog can be found here.](https://shoelace.style/resources/changelog/) diff --git a/packages/webawesome/src/components/animated-image/animated-image.css b/packages/webawesome/src/components/animated-image/animated-image.css index abf70ca93..8e1ab5df2 100644 --- a/packages/webawesome/src/components/animated-image/animated-image.css +++ b/packages/webawesome/src/components/animated-image/animated-image.css @@ -36,8 +36,10 @@ img[aria-hidden='true'] { transition: opacity var(--wa-transition-normal) var(--wa-transition-easing); } -:host([play]:hover) .control-box { - opacity: 1; +@media (hover: hover) { + :host([play]:hover) .control-box { + opacity: 1; + } } :host([play]:not(:hover)) .control-box { diff --git a/packages/webawesome/src/components/breadcrumb-item/breadcrumb-item.css b/packages/webawesome/src/components/breadcrumb-item/breadcrumb-item.css index 653d19346..4a98b92d2 100644 --- a/packages/webawesome/src/components/breadcrumb-item/breadcrumb-item.css +++ b/packages/webawesome/src/components/breadcrumb-item/breadcrumb-item.css @@ -26,8 +26,10 @@ transition: color var(--wa-transition-normal) var(--wa-transition-easing); } -:host(:not(:last-of-type)) .label:hover { - color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); +@media (hover: hover) { + :host(:not(:last-of-type)) .label:hover { + color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); + } } :host(:not(:last-of-type)) .label:active { diff --git a/packages/webawesome/src/components/button-group/button-group.css b/packages/webawesome/src/components/button-group/button-group.css index 794d9902a..185162a89 100644 --- a/packages/webawesome/src/components/button-group/button-group.css +++ b/packages/webawesome/src/components/button-group/button-group.css @@ -9,9 +9,11 @@ flex-wrap: wrap; gap: 1px; - > :hover, - &::slotted(:hover) { - z-index: 1; + @media (hover: hover) { + > :hover, + &::slotted(:hover) { + z-index: 1; + } } /* Focus and checked are always on top */ diff --git a/packages/webawesome/src/components/button/button.css b/packages/webawesome/src/components/button/button.css index de0c49bf8..a210ef143 100644 --- a/packages/webawesome/src/components/button/button.css +++ b/packages/webawesome/src/components/button/button.css @@ -51,10 +51,12 @@ } /* Interactive states */ -.button:not(.disabled):not(.loading):hover { - background-color: var(--background-color-hover, var(--background-color)); - border-color: var(--border-color-hover, var(--border-color, var(--background-color-hover))); - color: var(--text-color-hover, var(--text-color)); +@media (hover: hover) { + .button:not(.disabled):not(.loading):hover { + background-color: var(--background-color-hover, var(--background-color)); + border-color: var(--border-color-hover, var(--border-color, var(--background-color-hover))); + color: var(--text-color-hover, var(--text-color)); + } } .button:not(.disabled):not(.loading):active { diff --git a/packages/webawesome/src/components/copy-button/copy-button.css b/packages/webawesome/src/components/copy-button/copy-button.css index 0abbf7e15..e52a72b17 100644 --- a/packages/webawesome/src/components/copy-button/copy-button.css +++ b/packages/webawesome/src/components/copy-button/copy-button.css @@ -22,7 +22,13 @@ transition: color var(--wa-transition-fast) var(--wa-transition-easing); } -.button:hover:not([disabled]), +@media (hover: hover) { + .button:hover:not([disabled]) { + background-color: var(--background-color-hover); + color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); + } +} + .button:focus-visible:not([disabled]) { background-color: var(--background-color-hover); color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); diff --git a/packages/webawesome/src/components/input/input.css b/packages/webawesome/src/components/input/input.css index d3993f16c..3dc0f9a0f 100644 --- a/packages/webawesome/src/components/input/input.css +++ b/packages/webawesome/src/components/input/input.css @@ -166,8 +166,10 @@ textarea { transition: var(--wa-transition-normal) color; cursor: pointer; - &:hover { - color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); + @media (hover: hover) { + &:hover { + color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); + } } &:active { diff --git a/packages/webawesome/src/components/option/option.css b/packages/webawesome/src/components/option/option.css index 00bdb0ff7..149fb873b 100644 --- a/packages/webawesome/src/components/option/option.css +++ b/packages/webawesome/src/components/option/option.css @@ -23,9 +23,11 @@ outline: none; } -:host(:not([disabled], :state(current)):is(:state(hover), :hover)) { - background-color: var(--background-color-hover); - color: var(--text-color-hover); +@media (hover: hover) { + :host(:not([disabled], :state(current)):is(:state(hover), :hover)) { + background-color: var(--background-color-hover); + color: var(--text-color-hover); + } } :host(:state(current)), diff --git a/packages/webawesome/src/components/radio/radio.css b/packages/webawesome/src/components/radio/radio.css index e025190fe..827c18195 100644 --- a/packages/webawesome/src/components/radio/radio.css +++ b/packages/webawesome/src/components/radio/radio.css @@ -141,8 +141,10 @@ border-start-end-radius: 0; } -:host([appearance='button']:hover:not([disabled], :state(checked))) { - background-color: color-mix(in srgb, var(--wa-color-surface-default) 95%, var(--wa-color-mix-hover)); +@media (hover: hover) { + :host([appearance='button']:hover:not([disabled], :state(checked))) { + background-color: color-mix(in srgb, var(--wa-color-surface-default) 95%, var(--wa-color-mix-hover)); + } } :host([appearance='button']:focus-visible) { diff --git a/packages/webawesome/src/components/select/select.css b/packages/webawesome/src/components/select/select.css index a01aace87..2639c38e3 100644 --- a/packages/webawesome/src/components/select/select.css +++ b/packages/webawesome/src/components/select/select.css @@ -198,8 +198,10 @@ label:has(select), outline: none; } - &:hover { - color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); + @media (hover: hover) { + &:hover { + color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); + } } &:active { diff --git a/packages/webawesome/src/components/tab/tab.css b/packages/webawesome/src/components/tab/tab.css index 65f6078df..c782ca0d3 100644 --- a/packages/webawesome/src/components/tab/tab.css +++ b/packages/webawesome/src/components/tab/tab.css @@ -25,8 +25,10 @@ } } -:host(:hover:not([disabled])) .tab { - color: currentColor; +@media (hover: hover) { + :host(:hover:not([disabled])) .tab { + color: currentColor; + } } :host(:focus) { diff --git a/packages/webawesome/src/components/tag/tag.css b/packages/webawesome/src/components/tag/tag.css index 7b2f6059f..3fdabab5b 100644 --- a/packages/webawesome/src/components/tag/tag.css +++ b/packages/webawesome/src/components/tag/tag.css @@ -33,8 +33,10 @@ width: 1em; } -:host(:hover) > [part='remove-button']::part(base) { - color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); +@media (hover: hover) { + :host(:hover) > [part='remove-button']::part(base) { + color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); + } } :host(:active) > [part='remove-button']::part(base) { diff --git a/packages/webawesome/src/styles/native.css b/packages/webawesome/src/styles/native.css index c2205fbfe..8a0763bed 100644 --- a/packages/webawesome/src/styles/native.css +++ b/packages/webawesome/src/styles/native.css @@ -1173,12 +1173,14 @@ background-color: color-mix(in oklab, var(--wa-color-fill-quiet) 60%, transparent); } - &:hover { - background-color: var(--wa-color-fill-quiet); + @media (hover: hover) { + &:hover { + background-color: var(--wa-color-fill-quiet); - &, - + tr { - border-top-color: var(--wa-color-border-normal); + &, + + tr { + border-top-color: var(--wa-color-border-normal); + } } } } diff --git a/packages/webawesome/src/styles/themes/matter/overrides.css b/packages/webawesome/src/styles/themes/matter/overrides.css index 208918d13..84dbacb12 100644 --- a/packages/webawesome/src/styles/themes/matter/overrides.css +++ b/packages/webawesome/src/styles/themes/matter/overrides.css @@ -195,17 +195,21 @@ margin-inline-end: 0.75em; } - &:hover { - --box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color), transparent 85%); - &:is(:checked, :indeterminate, :state(checked), :state(indeterminate)) { - --box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color-checked), transparent 85%); + @media (hover: hover) { + &:hover { + --box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color), transparent 85%); + &:is(:checked, :indeterminate, :state(checked), :state(indeterminate)) { + --box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color-checked), transparent 85%); + } } } } - input[type='range']:hover, - wa-slider:hover { - --thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--thumb-color), transparent 85%); + @media (hover: hover) { + input[type='range']:hover, + wa-slider:hover { + --thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--thumb-color), transparent 85%); + } } wa-switch { @@ -220,8 +224,10 @@ margin-inline-end: 0.75em; } - &:hover { - --thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color), transparent 85%); + @media (hover: hover) { + &:hover { + --thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color), transparent 85%); + } } &:not(:state(checked))::part(thumb) { diff --git a/packages/webawesome/src/styles/themes/playful/dimension.css b/packages/webawesome/src/styles/themes/playful/dimension.css index 481aceb25..83dc276f1 100644 --- a/packages/webawesome/src/styles/themes/playful/dimension.css +++ b/packages/webawesome/src/styles/themes/playful/dimension.css @@ -11,8 +11,10 @@ /* Doesn't apply transform to buttons in dropdowns or button groups. * For dropdowns, this prevents the dropdown panel from shifting. */ &:not(:where(wa-button-group &, wa-dropdown &, wa-radio-group &)) { - &:hover { - transform: scale(1.02); + @media (hover: hover) { + &:hover { + transform: scale(1.02); + } } &:active { @@ -58,15 +60,17 @@ } &:not([disabled]) { - &:hover { - &:where(:not(wa-button)), - &::part(base) { - background: linear-gradient( - 180deg, - var(--gradient-bottom) 0%, - var(--gradient-middle) 51.88%, - var(--gradient-top) 100% - ); + @media (hover: hover) { + &:hover { + &:where(:not(wa-button)), + &::part(base) { + background: linear-gradient( + 180deg, + var(--gradient-bottom) 0%, + var(--gradient-middle) 51.88%, + var(--gradient-top) 100% + ); + } } } diff --git a/packages/webawesome/src/styles/utilities/text.css b/packages/webawesome/src/styles/utilities/text.css index edee398c3..059a85269 100644 --- a/packages/webawesome/src/styles/utilities/text.css +++ b/packages/webawesome/src/styles/utilities/text.css @@ -106,10 +106,12 @@ text-decoration: var(--wa-link-decoration-default); -webkit-text-decoration: var(--wa-link-decoration-default); - &:hover { - color: color-mix(in oklab, var(--wa-color-text-link) 100%, var(--wa-color-mix-hover)); - text-decoration: var(--wa-link-decoration-hover); - -webkit-text-decoration: var(--wa-link-decoration-hover); + @media (hover: hover) { + &:hover { + color: color-mix(in oklab, var(--wa-color-text-link) 100%, var(--wa-color-mix-hover)); + text-decoration: var(--wa-link-decoration-hover); + -webkit-text-decoration: var(--wa-link-decoration-hover); + } } } @@ -117,9 +119,11 @@ color: var(--wa-color-text-normal); text-decoration: none; - &:hover { - color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); - text-decoration: none; + @media (hover: hover) { + &:hover { + color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover)); + text-decoration: none; + } } } } From 2331e88dcfd4a096059ef8f87eb1c0ff86f39a67 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 6 Jun 2025 08:29:19 -0400 Subject: [PATCH 17/18] update jsdoc (#1038) --- packages/webawesome/src/components/dialog/dialog.ts | 5 +---- packages/webawesome/src/components/drawer/drawer.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/webawesome/src/components/dialog/dialog.ts b/packages/webawesome/src/components/dialog/dialog.ts index 95e4ad3ee..de94effca 100644 --- a/packages/webawesome/src/components/dialog/dialog.ts +++ b/packages/webawesome/src/components/dialog/dialog.ts @@ -63,10 +63,7 @@ export default class WaDialog extends WebAwesomeElement { @query('.dialog') dialog: HTMLDialogElement; - /** - * Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can - * use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state. - */ + /** Indicates whether or not the dialog is open. Toggle this attribute to show and hide the dialog. */ @property({ type: Boolean, reflect: true }) open = false; /** diff --git a/packages/webawesome/src/components/drawer/drawer.ts b/packages/webawesome/src/components/drawer/drawer.ts index 936db88fe..e5b40b007 100644 --- a/packages/webawesome/src/components/drawer/drawer.ts +++ b/packages/webawesome/src/components/drawer/drawer.ts @@ -68,10 +68,7 @@ export default class WaDrawer extends WebAwesomeElement { @query('.drawer') drawer: HTMLDialogElement; - /** - * Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can - * use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state. - */ + /** Indicates whether or not the drawer is open. Toggle this attribute to show and hide the drawer. */ @property({ type: Boolean, reflect: true }) open = false; /** From d21d829c299be9e6b66b14d2e6c089e6ae7b349d Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 6 Jun 2025 10:38:18 -0400 Subject: [PATCH 18/18] Slider rework (#1034) * initial rework * improvements * add size styles * update changelog * update changelog * fix hint aria * whitespace * slider test fixes (#1036) * prettier * More slider fixes 2 (#1037) * more fixes * more fixes * prettier * fix theme slider styles * only add extra space around slider when label is present --------- Co-authored-by: Konnor Rogers Co-authored-by: lindsaym-fa --- .../webawesome/docs/docs/components/slider.md | 315 +++-- .../docs/docs/resources/changelog.md | 10 + .../webawesome/src/components/input/input.ts | 48 +- .../src/components/slider/slider.css | 387 +++--- .../src/components/slider/slider.test.ts | 17 +- .../src/components/slider/slider.ts | 1035 +++++++++++++---- packages/webawesome/src/internal/drag.ts | 129 ++ .../src/internal/submit-on-enter.ts | 64 + .../internal/test/form-control-base-tests.ts | 1 + .../internal/validators/slider-validator.ts | 123 ++ .../src/styles/themes/active/dimension.css | 8 +- .../src/styles/themes/brutalist/overrides.css | 3 +- .../src/styles/themes/glossy/dimension.css | 7 +- .../src/styles/themes/matter/overrides.css | 5 +- .../src/styles/themes/playful/dimension.css | 25 +- .../src/styles/themes/shoelace/overrides.css | 5 +- 16 files changed, 1639 insertions(+), 543 deletions(-) create mode 100644 packages/webawesome/src/internal/submit-on-enter.ts create mode 100644 packages/webawesome/src/internal/validators/slider-validator.ts diff --git a/packages/webawesome/docs/docs/components/slider.md b/packages/webawesome/docs/docs/components/slider.md index 9400458b0..f4f7cda6d 100644 --- a/packages/webawesome/docs/docs/components/slider.md +++ b/packages/webawesome/docs/docs/components/slider.md @@ -7,7 +7,20 @@ icon: slider --- ```html {.example} - + + Less + More + ``` :::info @@ -18,7 +31,7 @@ This component works with standard `` elements. Please refer to the sectio ### Labels -Use the `label` attribute to give the range an accessible label. For labels that contain HTML, use the `label` slot instead. +Use the `label` attribute to give the slider an accessible label. For labels that contain HTML, use the `label` slot instead. ```html {.example} @@ -26,18 +39,233 @@ Use the `label` attribute to give the range an accessible label. For labels that ### Hint -Add descriptive hint to a range with the `hint` attribute. For hints that contain HTML, use the `hint` slot instead. +Add descriptive hint to a slider with the `hint` attribute. For hints that contain HTML, use the `hint` slot instead. ```html {.example} ``` -### Min, Max, and Step +### Showing tooltips -Use the `min` and `max` attributes to set the range's minimum and maximum values, respectively. The `step` attribute determines the value's interval when increasing and decreasing. +Use the `with-tooltip` attribute to display a tooltip with the current value when the slider is focused or being dragged. ```html {.example} - + +``` + +### Setting min, max, and step + +Use the `min` and `max` attributes to define the slider's range, and the `step` attribute to control the increment between values. + +```html {.example} + +``` + +### Showing markers + +Use the `with-markers` attribute to display visual indicators at each step increment. This works best with sliders that have a smaller range of values. + +```html {.example} + +``` + +### Adding references + +Use the `with-references` attribute along with the `reference` slot to add contextual labels below the slider. References are automatically spaced using `space-between`, making them easy to align with the start, center, and end positions. + +```html {.example} + + Slow + Medium + Fast + +``` + +:::info +If you want to show a reference next to a specific marker, you can add `position: absolute` to it and set the `left`, `right`, `top`, or `bottom` property to a percentage that corresponds to the marker's position. +::: + +### Formatting the value + +Customize how values are displayed in tooltips and announced to screen readers using the `valueFormatter` property. Set it to a function that accepts a number and returns a formatted string. The [`Intl.NumberFormat API`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) is particularly useful for this. + +```html {.example} + +
    + + + + +
    + + + + + + + +``` + +### Range selection + +Use the `range` attribute to enable dual-thumb selection for choosing a range of values. Set the initial thumb positions with the `min-value` and `max-value` attributes. + +```html {.example} + + $0 + $50 + $100 + + + +``` + +For range sliders, the `minValue` and `maxValue` properties represent the current positions of the thumbs. When the form is submitted, both values will be included as separate entries with the same name. + +```ts +const slider = document.querySelector('wa-slider[range]'); + +// Get the current values +console.log(`Min value: ${slider.minValue}, Max value: ${slider.maxValue}`); + +// Set the values programmatically +slider.minValue = 30; +slider.maxValue = 70; +``` + +### Vertical Sliders + +Set the `orientation` attribute to `vertical` to create a vertical slider. Vertical sliders automatically center themselves and fill the available vertical space. + +```html {.example} +
    + + + + + +
    +``` + +Range sliders can also be vertical. + +```html {.example} +
    + + +
    + + +``` + +### Size + +Control the slider's size using the `size` attribute. Valid options include `small`, `medium`, and `large`. + +```html {.example} +
    +
    + +``` + +### Indicator Offset + +By default, the filled indicator extends from the minimum value to the current position. Use the `indicator-offset` attribute to change the starting point of this visual indicator. + +```html {.example} + + Lazy + Zoomies + ``` ### Disabled @@ -45,74 +273,17 @@ Use the `min` and `max` attributes to set the range's minimum and maximum values Use the `disabled` attribute to disable a slider. ```html {.example} - + ``` -### Tooltip Placement +### Required -By default, the tooltip is shown on top. Set `tooltip` to `bottom` to show it below the slider. +Mark a slider as required using the `required` attribute. Users must interact with required sliders before the form can be submitted. ```html {.example} - -``` - -### Disable the Tooltip - -To disable the tooltip, set `tooltip` to `none`. - -```html {.example} - -``` - -### Custom Track Colors - -You can customize the active and inactive portions of the track using the `--track-color-active` and `--track-color-inactive` custom properties. - -```html {.example} - -``` - -### Custom Track Offset - -You can customize the initial offset of the active track using the `--track-active-offset` custom property. - -```html {.example} - -``` - -### Custom Tooltip Formatter - -You can change the tooltip's content by setting the `tooltipFormatter` property to a function that accepts the range's value as an argument. - -```html {.example} - - - -``` - -### Right-to-Left languages - -The component adapts to right-to-left (RTL) languages as you would expect. - -```html {.example} - -``` + + +
    + + +``` \ No newline at end of file diff --git a/packages/webawesome/docs/docs/resources/changelog.md b/packages/webawesome/docs/docs/resources/changelog.md index 77ce2f1f0..12641b9dc 100644 --- a/packages/webawesome/docs/docs/resources/changelog.md +++ b/packages/webawesome/docs/docs/resources/changelog.md @@ -38,6 +38,16 @@ During the alpha period, things might break! We take breaking changes very serio - Added convenience tokens for `--wa-font-size-smaller` and `--wa-font-size-larger` - Updated components to use relative `em` values for internal padding and margin wherever appropriate - 🚨 BREAKING: removed the `hint` property and slot from ``; please apply hints directly to `` instead +- 🚨 BREAKING: redesigned `` with extensive new functionality + - Added support for range sliders with dual thumbs using the `range` attribute + - Added vertical orientation support with `orientation="vertical"` + - Added visual markers at each step with `with-markers` + - Added contextual reference labels with `with-references` and the `reference` slot + - Added tooltips showing current values with `with-tooltip` + - Added customizable indicator offset with `indicator-offset` attribute + - Added value formatting support with the `valueFormatter` property + - Improved the styling API to be consistent and more powerful (no more browser-specific selectors and pseudo elements to style) + - Updated to use consistent `with-*` attribute naming pattern - 🚨 BREAKING: removed ``; use `` instead - Added a new free component: `` (#2 of 14 per stretch goals) - Added a new free component: `` (#3 of 14 per stretch goals) diff --git a/packages/webawesome/src/components/input/input.ts b/packages/webawesome/src/components/input/input.ts index 5ffe73c8f..e615f0e8d 100644 --- a/packages/webawesome/src/components/input/input.ts +++ b/packages/webawesome/src/components/input/input.ts @@ -5,6 +5,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import { WaClearEvent } from '../../events/clear.js'; import { HasSlotController } from '../../internal/slot.js'; +import { submitOnEnter } from '../../internal/submit-on-enter.js'; import { MirrorValidator } from '../../internal/validators/mirror-validator.js'; import { watch } from '../../internal/watch.js'; import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-form-associated-element.js'; @@ -12,7 +13,6 @@ import formControlStyles from '../../styles/component/form-control.css'; import appearanceStyles from '../../styles/utilities/appearance.css'; import sizeStyles from '../../styles/utilities/size.css'; import { LocalizeController } from '../../utilities/localize.js'; -import type WaButton from '../button/button.js'; import '../icon/icon.js'; import styles from './input.css'; @@ -245,51 +245,7 @@ export default class WaInput extends WebAwesomeFormAssociatedElement { } private handleKeyDown(event: KeyboardEvent) { - const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; - - // Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before - // submitting to allow users to cancel the keydown event if they need to - if (event.key === 'Enter' && !hasModifier) { - setTimeout(() => { - // - // When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way - // to check for this is to look at event.isComposing, which will be true when the IME is open. - // - // See https://github.com/shoelace-style/shoelace/pull/988 - // - if (!event.defaultPrevented && !event.isComposing) { - const form = this.getForm(); - - if (!form) { - return; - } - - const formElements = [...form.elements]; - - // If we're the only formElement, we submit like a native input. - if (formElements.length === 1) { - form.requestSubmit(null); - return; - } - - const button = formElements.find( - (el: HTMLButtonElement) => el.type === 'submit' && !el.matches(':disabled'), - ) as undefined | HTMLButtonElement | WaButton; - - // No button found, don't submit. - if (!button) { - return; - } - - if (button.tagName.toLowerCase() === 'button') { - form.requestSubmit(button); - } else { - // requestSubmit() wont work with `` - button.click(); - } - } - }); - } + submitOnEnter(event, this); } private handlePasswordToggle() { diff --git a/packages/webawesome/src/components/slider/slider.css b/packages/webawesome/src/components/slider/slider.css index 13eee9922..f0ee323ac 100644 --- a/packages/webawesome/src/components/slider/slider.css +++ b/packages/webawesome/src/components/slider/slider.css @@ -1,214 +1,227 @@ :host { - --thumb-color: var(--wa-form-control-activated-color); - --thumb-gap: calc(var(--thumb-size) * 0.125); - --thumb-shadow: initial; - --thumb-size: calc(1em * var(--wa-form-control-value-line-height)); - - --track-color-active: var(--wa-color-neutral-fill-normal); - --track-color-inactive: var(--wa-color-neutral-fill-normal); - --track-active-offset: 0%; - --track-height: calc(var(--thumb-size) * 0.25); - --tooltip-offset: calc(var(--wa-tooltip-arrow-size) * 1.375); - - display: flex; - flex-direction: column; - position: relative; - min-height: max(var(--thumb-size), var(--track-height)); + --track-size: 0.5em; + --thumb-width: 1.4em; + --thumb-height: 1.4em; + --marker-width: 0.1875em; + --marker-height: 0.1875em; } -input[type='range'] { - --percent: 0%; - -webkit-appearance: none; - border-radius: calc(var(--track-height) / 2); - width: 100%; - height: var(--track-height); - font-size: inherit; - line-height: var(--wa-form-control-height); - vertical-align: middle; - margin: 0; - --dir: right; +:host([orientation='vertical']) { + width: auto; +} - background-image: linear-gradient( - to var(--dir), - var(--track-color-inactive) min(var(--percent), var(--track-active-offset)), - var(--track-color-active) min(var(--percent), var(--track-active-offset)), - var(--track-color-active) max(var(--percent), var(--track-active-offset)), - var(--track-color-inactive) max(var(--percent), var(--track-active-offset)) - ); +#label:has(~ .vertical) { + display: block; + order: 2; + max-width: none; + text-align: center; +} + +#description:has(~ .vertical) { + order: 3; + text-align: center; +} + +/* Add extra space between slider and label, when present */ +#label:has(*:not(:empty)) ~ #slider { + &.horizontal { + margin-block-start: 0.5em; + } + &.vertical { + margin-block-end: 0.5em; + } +} + +#slider { + &:focus { + outline: none; + } + + &:focus-visible:not(.disabled) #thumb, + &:focus-visible:not(.disabled) #thumb-min, + &:focus-visible:not(.disabled) #thumb-max { + outline: var(--wa-focus-ring); + /* intentionally no offset due to border */ + } +} + +#track { + position: relative; + border-radius: 9999px; + background: var(--wa-color-neutral-fill-normal); + isolation: isolate; +} + +/* Orientation */ +.horizontal #track { + height: var(--track-size); +} + +.vertical #track { + order: 1; + width: var(--track-size); + height: 200px; +} + +/* Disabled */ +.disabled #track { + cursor: not-allowed; + opacity: 0.5; +} + +/* Indicator */ +#indicator { + position: absolute; + border-radius: inherit; + background-color: var(--wa-form-control-activated-color); + + &:dir(ltr) { + right: calc(100% - max(var(--start), var(--end))); + left: min(var(--start), var(--end)); + } &:dir(rtl) { - --dir: left; - } - - &::-webkit-slider-runnable-track { - width: 100%; - height: var(--track-height); - border-radius: 3px; - border: none; - } - - &::-webkit-slider-thumb { - width: var(--thumb-size); - height: var(--thumb-size); - border-radius: 50%; - background-color: var(--thumb-color); - box-shadow: - var(--thumb-shadow, 0 0 transparent), - 0 0 0 var(--thumb-gap) var(--wa-color-surface-default); - -webkit-appearance: none; - margin-top: calc(var(--thumb-size) / -2 + var(--track-height) / 2); - transition: var(--wa-transition-fast); - transition-property: width, height; - } - - &:enabled { - &:focus-visible::-webkit-slider-thumb { - outline: var(--wa-focus-ring); - outline-offset: var(--wa-focus-ring-offset); - } - - &::-webkit-slider-thumb { - cursor: pointer; - } - - &::-webkit-slider-thumb:active { - cursor: grabbing; - } - } - - &::-moz-focus-outer { - border: 0; - } - - &::-moz-range-progress { - background-color: var(--track-color-active); - border-radius: 3px; - height: var(--track-height); - } - - &::-moz-range-track { - width: 100%; - height: var(--track-height); - background-color: var(--track-color-inactive); - border-radius: 3px; - border: none; - } - - &::-moz-range-thumb { - height: var(--thumb-size); - width: var(--thumb-size); - border-radius: 50%; - background-color: var(--thumb-color); - box-shadow: - var(--thumb-shadow, 0 0 transparent), - 0 0 0 var(--thumb-gap) var(--wa-color-surface-default); - transition-property: background-color, border-color, box-shadow, color; - transition-duration: var(--wa-transition-normal); - transition-timing-function: var(--wa-transition-easing); - } - - &:enabled { - &:focus-visible::-moz-range-thumb { - outline: var(--wa-focus-ring); - outline-offset: var(--wa-focus-ring-offset); - } - - &::-moz-range-thumb { - cursor: pointer; - } - - &::-moz-range-thumb:active { - cursor: grabbing; - } + right: min(var(--start), var(--end)); + left: calc(100% - max(var(--start), var(--end))); } } -/* States */ -/* nesting these styles yields broken results in Safari */ -input[type='range']:focus { - outline: none; +.horizontal #indicator { + top: 0; + height: 100%; } -:host :has(:disabled) input[type='range'] { - opacity: 0.5; - cursor: not-allowed; - - &::-moz-range-thumb, - &::-webkit-slider-thumb { - cursor: not-allowed; - } +.vertical #indicator { + top: calc(100% - var(--end)); + bottom: var(--start); + left: 0; + width: 100%; } -/* Tooltip output */ -.tooltip { +/* Thumbs */ +#thumb, +#thumb-min, +#thumb-max { + z-index: 3; position: absolute; - z-index: 1000; - inset-inline-start: 0; + width: var(--thumb-width); + height: var(--thumb-height); + border: solid 0.125em var(--wa-color-surface-default); + border-radius: 50%; + background-color: var(--wa-form-control-activated-color); + cursor: pointer; +} - inset-block-end: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset)); - border-radius: var(--wa-tooltip-border-radius); - background-color: var(--wa-tooltip-background-color); - font-family: inherit; - font-size: var(--wa-tooltip-font-size); - line-height: var(--wa-tooltip-line-height); - color: var(--wa-tooltip-content-color); - opacity: 0; - padding: 0.25em 0.5em; - transition: var(--wa-transition-normal) opacity; +.disabled #thumb, +.disabled #thumb-min, +.disabled #thumb-max { + cursor: inherit; +} + +.horizontal #thumb, +.horizontal #thumb-min, +.horizontal #thumb-max { + top: calc(50% - var(--thumb-height) / 2); + + &:dir(ltr) { + right: auto; + left: calc(var(--position) - var(--thumb-width) / 2); + } + + &:dir(rtl) { + right: calc(var(--position) - var(--thumb-width) / 2); + left: auto; + } +} + +.vertical #thumb, +.vertical #thumb-min, +.vertical #thumb-max { + bottom: calc(var(--position) - var(--thumb-height) / 2); + left: calc(50% - var(--thumb-width) / 2); +} + +/* Range-specific thumb styles */ +:host([range]) { + #thumb-min:focus-visible, + #thumb-max:focus-visible { + z-index: 4; /* Ensure focused thumb appears on top */ + outline: var(--wa-focus-ring); + /* intentionally no offset due to border */ + } +} + +/* Markers */ +#markers { pointer-events: none; +} - &::after { - content: ''; - position: absolute; - width: 0; - height: 0; - inset-inline-start: 50%; - inset-block-start: 100%; - translate: calc(-1 * var(--wa-tooltip-arrow-size)); - border-inline: var(--wa-tooltip-arrow-size) solid transparent; - border-block-start: var(--border-block); +.marker { + z-index: 2; + position: absolute; + width: var(--marker-width); + height: var(--marker-height); + border-radius: 50%; + background-color: var(--wa-color-surface-default); +} + +.marker:first-of-type, +.marker:last-of-type { + display: none; +} + +.horizontal .marker { + top: calc(50% - var(--marker-height) / 2); + left: calc(var(--position) - var(--marker-width) / 2); +} + +.vertical .marker { + top: calc(var(--position) - var(--marker-height) / 2); + left: calc(50% - var(--marker-width) / 2); +} + +/* Marker labels */ +#references { + position: relative; + + slot { + display: flex; + justify-content: space-between; + height: 100%; } - &:dir(rtl)::after { - translate: var(--wa-tooltip-arrow-size); + ::slotted(*) { + color: var(--wa-color-text-quiet); + font-size: 0.875em; + line-height: 1; + } +} + +.horizontal { + #references { + margin-block-start: 0.5em; + } +} + +.vertical { + display: flex; + margin-inline: auto; + + #track { + order: 1; } - &.visible { - opacity: 1; - } + #references { + order: 2; + width: min-content; + margin-inline-start: 0.75em; - --inset-block: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset)); - --border-block: var(--wa-tooltip-arrow-size) solid var(--wa-tooltip-background-color); - - @media (forced-colors: active) { - border: solid 1px transparent; - - &::after { - display: none; + slot { + flex-direction: column; } } } -/* RTL tooltip positioning */ -:host(:dir(rtl)) .tooltip { - inset-inline-start: auto; - inset-inline-end: 0; -} - -/* Tooltip on bottom */ -:host([tooltip='bottom']) .tooltip { - inset-block-end: auto; - inset-block-start: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset)); - - &::after { - border-block-end: var(--border-block); - inset-block-start: auto; - inset-block-end: 100%; - } -} - -/* Bottom tooltip RTL fix */ -:host([tooltip='bottom']:dir(rtl)) .tooltip { - inset-inline-start: auto; - inset-inline-end: 0; +.vertical #references slot { + flex-direction: column; } diff --git a/packages/webawesome/src/components/slider/slider.test.ts b/packages/webawesome/src/components/slider/slider.test.ts index 99c92c9c2..3594c26e3 100644 --- a/packages/webawesome/src/components/slider/slider.test.ts +++ b/packages/webawesome/src/components/slider/slider.test.ts @@ -21,9 +21,8 @@ describe('', () => { it('default properties', async () => { const el = await fixture(html` `); - expect(el.name).to.equal(''); + expect(el.name).to.equal(null); expect(el.value).to.equal(0); - expect(el.title).to.equal(''); expect(el.label).to.equal(''); expect(el.hint).to.equal(''); expect(el.disabled).to.be.false; @@ -31,22 +30,16 @@ describe('', () => { expect(el.min).to.equal(0); expect(el.max).to.equal(100); expect(el.step).to.equal(1); - expect(el.tooltip).to.equal('top'); + expect(el.tooltipPlacement).to.equal('top'); expect(el.defaultValue).to.equal(0); }); - it('should have title if title attribute is set', async () => { - const el = await fixture(html` `); - const input = el.shadowRoot!.querySelector('input')!; - - expect(input.title).to.equal('Test'); - }); - it('should be disabled with the disabled attribute', async () => { const el = await fixture(html` `); - const input = el.shadowRoot!.querySelector('.control')!; + const input = el.shadowRoot!.querySelector("[role='slider']")!; - expect(input.disabled).to.be.true; + expect(el.matches(':disabled')).to.be.true; + expect(input.getAttribute('aria-disabled')).to.equal('true'); }); describe('when the value changes', () => { diff --git a/packages/webawesome/src/components/slider/slider.ts b/packages/webawesome/src/components/slider/slider.ts index 00a724fe2..b0cc5dc4b 100644 --- a/packages/webawesome/src/components/slider/slider.ts +++ b/packages/webawesome/src/components/slider/slider.ts @@ -1,24 +1,34 @@ +import type { PropertyValues } from 'lit'; import { html } from 'lit'; -import { customElement, eventOptions, property, query, state } from 'lit/decorators.js'; +import { customElement, property, query, queryAll, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { live } from 'lit/directives/live.js'; +import { DraggableElement } from '../../internal/drag.js'; +import { clamp } from '../../internal/math.js'; import { HasSlotController } from '../../internal/slot.js'; -import { MirrorValidator } from '../../internal/validators/mirror-validator.js'; -import { watch } from '../../internal/watch.js'; +import { submitOnEnter } from '../../internal/submit-on-enter.js'; +import { SliderValidator } from '../../internal/validators/slider-validator.js'; import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-form-associated-element.js'; import formControlStyles from '../../styles/component/form-control.css'; +import sizeStyles from '../../styles/utilities/size.css'; import { LocalizeController } from '../../utilities/localize.js'; +import '../tooltip/tooltip.js'; +import type WaTooltip from '../tooltip/tooltip.js'; import styles from './slider.css'; /** + * + * * @summary Ranges allow the user to select a single value within a given range using a slider. * @documentation https://backers.webawesome.com/docs/components/range * @status stable * @since 2.0 * + * @dependency wa-tooltip + * * @slot label - The slider label. Alternatively, you can use the `label` attribute. * @slot hint - Text that describes how to use the input. Alternatively, you can use the `hint` attribute. + * instead. + * @slot reference - One or more reference labels to show visually below the slider. * * @event blur - Emitted when the control loses focus. * @event change - Emitted when an alteration to the control's value is committed by the user. @@ -26,61 +36,107 @@ import styles from './slider.css'; * @event input - Emitted when the control receives input. * @event wa-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. * - * @csspart form-control - The form control that wraps the label, input, and hint. - * @csspart form-control-label - The input's label. - * @csspart form-control-input - The input's wrapper. - * @csspart hint - The hint's wrapper. - * @csspart base - The internal `` element. - * @csspart tooltip - The slider tooltip. + * @csspart label - The element that contains the sliders's label. + * @csspart hint - The element that contains the slider's description. + * @csspart slider - The focusable element with `role="slider"`. Contains the track and reference slot. + * @csspart track - The slider's track. + * @csspart indicator - The colored indicator that shows from the start of the slider to the current value. + * @csspart markers - The container that holds all the markers when `with-markers` is used. + * @csspart marker - The individual markers that are shown when `with-markers` is used. + * @csspart references - The container that holds references that get slotted in. + * @csspart thumb - The slider's thumb. + * @csspart thumb-min - The min value thumb in a range slider. + * @csspart thumb-max - The max value thumb in a range slider. + * @csspart tooltip - The tooltip, a `` element. + * @csspart tooltip__tooltip - The tooltip's `tooltip` part. + * @csspart tooltip__content - The tooltip's `content` part. + * @csspart tooltip__arrow - The tooltip's `arrow` part. * - * @cssproperty --thumb-color - The color of the thumb. - * @cssproperty --thumb-gap - The visual gap between the edges of the thumb and the track. - * @cssproperty --thumb-shadow - The shadow effects around the edges of the thumb. - * @cssproperty --thumb-size - The size of the thumb. - * @cssproperty --tooltip-offset - The vertical distance the tooltip is offset from the thumb. - * @cssproperty --track-color-active - The color of the portion of the track that represents the current value. - * @cssproperty --track-color-inactive - The of the portion of the track that represents the remaining value. - * @cssproperty --track-height - The height of the track. - * @cssproperty --track-active-offset - The point of origin of the active track. + * @cssstate disabled - Applied when the slider is disabled. + * @cssstate dragging - Applied when the slider is being dragged. + * @cssstate focused - Applied when the slider has focus. + * @cssstate user-valid - Applied when the slider is valid and the user has sufficiently interacted with it. + * @cssstate user-invalid - Applied when the slider is invalid and the user has sufficiently interacted with it. + * + * @cssproperty [--track-size=0.75em] - The height or width of the slider's track. + * @cssproperty [--marker-width=0.1875em] - The width of each individual marker. + * @cssproperty [--marker-height=0.1875em] - The height of each individual marker. + * @cssproperty [--thumb-width=1.25em] - The width of the thumb. + * @cssproperty [--thumb-height=1.25em] - The height of the thumb. */ @customElement('wa-slider') export default class WaSlider extends WebAwesomeFormAssociatedElement { - static css = [formControlStyles, styles]; + static formAssociated = true; + static observeSlots = true; + static css = [sizeStyles, formControlStyles, styles]; static get validators() { - return [...super.validators, MirrorValidator()]; + return [...super.validators, SliderValidator()]; } + private draggableTrack: DraggableElement; + private draggableThumbMin: DraggableElement | null = null; + private draggableThumbMax: DraggableElement | null = null; private readonly hasSlotController = new HasSlotController(this, 'hint', 'label'); private readonly localize = new LocalizeController(this); - private resizeObserver: ResizeObserver; + private trackBoundingClientRect: DOMRect; + private valueWhenDraggingStarted: number | undefined | null; + private activeThumb: 'min' | 'max' | null = null; + private lastTrackPosition: number | null = null; // Track last position for direction detection - @query('.control') input: HTMLInputElement; - @query('.tooltip') output: HTMLOutputElement | null; + protected get focusableAnchor() { + return this.isRange ? this.thumbMin || this.slider : this.slider; + } - @state() private hasTooltip = false; - @property() title = ''; // make reactive to pass through + /** Override validation target to point to the focusable element */ + get validationTarget() { + return this.focusableAnchor; + } - /** The name of the slider, submitted as a name/value pair with form data. */ - @property() name: string = ''; + @query('#slider') slider: HTMLElement; + @query('#thumb') thumb: HTMLElement; + @query('#thumb-min') thumbMin: HTMLElement; + @query('#thumb-max') thumbMax: HTMLElement; + @query('#track') track: HTMLElement; + @query('#tooltip') tooltip: WaTooltip; + @queryAll('wa-tooltip') tooltips: NodeListOf; + + /** + * The slider's label. If you need to provide HTML in the label, use the `label` slot instead. + */ + @property() label: string = ''; + + /** The slider hint. If you need to display HTML, use the hint slot instead. */ + @property({ attribute: 'hint' }) hint = ''; + + /** The name of the slider. This will be submitted with the form as a name/value pair. */ + @property({ reflect: true }) name: string; + + /** The minimum value of a range selection. Used only when range attribute is set. */ + @property({ type: Number, attribute: 'min-value' }) minValue = 0; + + /** The maximum value of a range selection. Used only when range attribute is set. */ + @property({ type: Number, attribute: 'max-value' }) maxValue = 50; /** The default value of the form control. Primarily used for resetting the form control. */ - @property({ type: Number, attribute: 'value', reflect: true }) defaultValue: number = - Number(this.getAttribute('value')) || 0; + @property({ attribute: 'value', reflect: true, type: Number }) defaultValue: number = + this.getAttribute('value') == null ? this.minValue : Number(this.getAttribute('value')); - private _value: number | null = null; + private _value: number = this.defaultValue; /** The current value of the slider, submitted as a name/value pair with form data. */ get value(): number { if (this.valueHasChanged) { - return this._value || 0; + return this._value; } - return this._value ?? (this.defaultValue || 0); + return this._value ?? this.defaultValue; } @state() set value(val: number | null) { + val = Number(val) ?? this.minValue; + if (this._value === val) { return; } @@ -89,234 +145,817 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement { this._value = val; } - /** The slider label. If you need to display HTML, use the `label` slot instead. */ - @property() label = ''; + /** Converts the slider to a range slider with two thumbs. */ + @property({ type: Boolean, reflect: true }) range = false; - /** The slider hint. If you need to display HTML, use the hint slot instead. */ - @property({ attribute: 'hint' }) hint = ''; + /** Get if this is a range slider */ + get isRange(): boolean { + return this.range; + } /** Disables the slider. */ @property({ type: Boolean }) disabled = false; - /** The minimum acceptable value of the slider. */ - @property({ type: Number }) min = 0; + /** Makes the slider a read-only field. */ + @property({ type: Boolean, reflect: true }) readonly = false; - /** The maximum acceptable value of the slider. */ - @property({ type: Number }) max = 100; + /** The orientation of the slider. */ + @property({ reflect: true }) orientation: 'horizontal' | 'vertical' = 'horizontal'; - /** The interval at which the slider will increase and decrease. */ - @property({ type: Number }) step = 1; + /** The slider's size. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; - /** The preferred placement of the slider tooltip. */ - @property() tooltip: 'top' | 'bottom' | 'none' = 'top'; + /** The starting value from which to draw the slider's fill, which is based on its current value. */ + @property({ attribute: 'indicator-offset', type: Number }) indicatorOffset: number; /** - * A function used to format the tooltip's value. The slider value is passed as the first and only argument. The - * function should return a string to display in the tooltip. + * The form to associate this control with. If omitted, the closest containing `
    ` will be used. The value of + * this attribute must be an ID of a form in the same document or shadow root. */ - @property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString(); + @property({ reflect: true }) form = null; + + /** The minimum value allowed. */ + @property({ type: Number }) min: number = 0; + + /** The maximum value allowed. */ + @property({ type: Number }) max: number = 100; + + /** The granularity the value must adhere to when incrementing and decrementing. */ + @property({ type: Number }) step: number = 1; + + /** Makes the slider a required field. */ + @property({ type: Boolean, reflect: true }) required = false; + + /** Tells the browser to focus the slider when the page loads or a dialog is shown. */ + @property({ type: Boolean }) autofocus: boolean; + + /** The distance of the tooltip from the slider's thumb. */ + @property({ attribute: 'tooltip-distance', type: Number }) tooltipDistance = 8; + + /** The placement of the tooltip in reference to the slider's thumb. */ + @property({ attribute: 'tooltip-placement', reflect: true }) tooltipPlacement: 'top' | 'right' | 'bottom' | 'left' = + 'top'; + + /** Draws markers at each step along the slider. */ + @property({ attribute: 'with-markers', type: Boolean }) withMarkers = false; + + /** Renders the slider with the `references` slot. */ + @property({ attribute: 'with-references', type: Boolean, reflect: true }) withReferences = false; + + /** Draws a tooltip above the thumb when the control has focus or is dragged. */ + @property({ attribute: 'with-tooltip', type: Boolean }) withTooltip = false; /** - * By default, form controls are associated with the nearest containing `` element. This attribute allows you - * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in - * the same document or shadow root for this to work. + * A custom formatting function to apply to the value. This will be shown in the tooltip and announced by screen + * readers. Must be set with JavaScript. Property only. */ - @property({ reflect: true }) form: string | null = null; + @property({ attribute: false }) valueFormatter: (value: number) => string; - /** - * Used for SSR to render slotted labels. If true, will render slotted label content on first paint. - */ - @property({ attribute: 'with-label', reflect: true, type: Boolean }) withLabel = false; + firstUpdated() { + // Setup dragging based on range or single thumb mode + if (this.isRange) { + // Enable dragging on both thumbs for range slider + this.draggableThumbMin = new DraggableElement(this.thumbMin, { + start: () => { + this.activeThumb = 'min'; + this.trackBoundingClientRect = this.track.getBoundingClientRect(); + this.valueWhenDraggingStarted = this.minValue; + this.customStates.set('dragging', true); + this.showRangeTooltips(); + }, + move: (x, y) => { + this.setThumbValueFromCoordinates(x, y, 'min'); + }, + stop: () => { + if (this.minValue !== this.valueWhenDraggingStarted) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + this.hideRangeTooltips(); + this.customStates.set('dragging', false); + this.valueWhenDraggingStarted = undefined; + this.activeThumb = null; + }, + }); - /** - * Used for SSR to render slotted labels. If true, will render slotted hint content on first paint. - */ - @property({ attribute: 'with-hint', reflect: true, type: Boolean }) withHint = false; + this.draggableThumbMax = new DraggableElement(this.thumbMax, { + start: () => { + this.activeThumb = 'max'; + this.trackBoundingClientRect = this.track.getBoundingClientRect(); + this.valueWhenDraggingStarted = this.maxValue; + this.customStates.set('dragging', true); + this.showRangeTooltips(); + }, + move: (x, y) => { + this.setThumbValueFromCoordinates(x, y, 'max'); + }, + stop: () => { + if (this.maxValue !== this.valueWhenDraggingStarted) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + this.hideRangeTooltips(); + this.customStates.set('dragging', false); + this.valueWhenDraggingStarted = undefined; + this.activeThumb = null; + }, + }); - connectedCallback() { - super.connectedCallback(); - this.resizeObserver = new ResizeObserver(() => this.syncRange()); + // Enable track dragging for finding the closest thumb + this.draggableTrack = new DraggableElement(this.track, { + start: (x, y) => { + this.trackBoundingClientRect = this.track.getBoundingClientRect(); - if (this.value < this.min) { - this.value = this.min; + // When a drag starts, we need to determine which thumb to move + // If the thumbs are in nearly the same position, we prioritize the one that's already active + // or the one that received focus most recently + if (this.activeThumb) { + // Keep using the already active thumb (useful for keyboard interactions) + this.valueWhenDraggingStarted = this.activeThumb === 'min' ? this.minValue : this.maxValue; + } else { + // Otherwise select by closest distance + const value = this.getValueFromCoordinates(x, y); + const minDistance = Math.abs(value - this.minValue); + const maxDistance = Math.abs(value - this.maxValue); + + if (minDistance === maxDistance) { + // If distances are equal, prioritize the max thumb when value is higher than both thumbs + // and min thumb when value is lower than both thumbs + if (value > this.maxValue) { + this.activeThumb = 'max'; + } else if (value < this.minValue) { + this.activeThumb = 'min'; + } else { + // If the value is between the thumbs and they're at the same distance, + // prioritize the thumb that's in the direction of movement + const isRtl = this.localize.dir() === 'rtl'; + const isVertical = this.orientation === 'vertical'; + const position = isVertical ? y : x; + const previousPosition = this.lastTrackPosition || position; + this.lastTrackPosition = position; + + // Determine direction of movement + const movingForward = + (position > previousPosition !== isRtl && !isVertical) || (position < previousPosition && isVertical); + + this.activeThumb = movingForward ? 'max' : 'min'; + } + } else { + // Select the closest thumb + this.activeThumb = minDistance <= maxDistance ? 'min' : 'max'; + } + + this.valueWhenDraggingStarted = this.activeThumb === 'min' ? this.minValue : this.maxValue; + } + + this.customStates.set('dragging', true); + this.setThumbValueFromCoordinates(x, y, this.activeThumb); + this.showRangeTooltips(); + }, + move: (x, y) => { + if (this.activeThumb) { + this.setThumbValueFromCoordinates(x, y, this.activeThumb); + } + }, + stop: () => { + if (this.activeThumb) { + const currentValue = this.activeThumb === 'min' ? this.minValue : this.maxValue; + if (currentValue !== this.valueWhenDraggingStarted) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + } + this.hideRangeTooltips(); + this.customStates.set('dragging', false); + this.valueWhenDraggingStarted = undefined; + this.activeThumb = null; + }, + }); + } else { + // Single thumb mode - original behavior + this.draggableTrack = new DraggableElement(this.slider, { + start: (x, y) => { + this.trackBoundingClientRect = this.track.getBoundingClientRect(); + this.valueWhenDraggingStarted = this.value; + this.customStates.set('dragging', true); + this.setValueFromCoordinates(x, y); + this.showTooltip(); + }, + move: (x, y) => { + this.setValueFromCoordinates(x, y); + }, + stop: () => { + if (this.value !== this.valueWhenDraggingStarted) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + this.hideTooltip(); + this.customStates.set('dragging', false); + this.valueWhenDraggingStarted = undefined; + }, + }); } - if (this.value > this.max) { - this.value = this.max; + } + + updated(changedProperties: PropertyValues) { + // Handle range mode changes + if (changedProperties.has('range')) { + this.requestUpdate(); } - this.updateComplete.then(() => { - this.syncRange(); - this.resizeObserver.observe(this.input); - }); + if (this.isRange) { + // Handle min/max values for range mode + if (changedProperties.has('minValue') || changedProperties.has('maxValue')) { + // Ensure min doesn't exceed max + this.minValue = clamp(this.minValue, this.min, this.maxValue); + this.maxValue = clamp(this.maxValue, this.minValue, this.max); + // Update form value + this.updateFormValue(); + } + } else { + // Handle value for single thumb mode + if (changedProperties.has('value')) { + this.value = clamp(this.value, this.min, this.max); + this.setValue(String(this.value)); + } + } + + // Handle min/max + if (changedProperties.has('min') || changedProperties.has('max')) { + if (this.isRange) { + this.minValue = clamp(this.minValue, this.min, this.max); + this.maxValue = clamp(this.maxValue, this.min, this.max); + } else { + this.value = clamp(this.value, this.min, this.max); + } + } + + // Handle disabled + if (changedProperties.has('disabled')) { + this.customStates.set('disabled', this.disabled); + } + + // Disable dragging when disabled or readonly + if (changedProperties.has('disabled') || changedProperties.has('readonly')) { + const enabled = !(this.disabled || this.readonly); + + if (this.isRange) { + if (this.draggableThumbMin) this.draggableThumbMin.toggle(enabled); + if (this.draggableThumbMax) this.draggableThumbMax.toggle(enabled); + } + + if (this.draggableTrack) { + this.draggableTrack.toggle(enabled); + } + } + + super.updated(changedProperties); } - disconnectedCallback() { - super.disconnectedCallback(); - this.resizeObserver?.unobserve(this.input); + /** @internal Called when a containing fieldset is disabled. */ + formDisabledCallback(isDisabled: boolean) { + this.disabled = isDisabled; } - private handleChange(event: Event) { - this.relayNativeEvent(event, { bubbles: true, composed: true }); + /** @internal Called when the form is reset. */ + formResetCallback() { + if (this.isRange) { + this.minValue = parseFloat(this.getAttribute('min-value') ?? String(this.min)); + this.maxValue = parseFloat(this.getAttribute('max-value') ?? String(this.max)); + } else { + this.value = parseFloat(this.getAttribute('value') ?? String(this.min)); + } + this.hasInteracted = false; + super.formResetCallback(); } - private handleInput() { - this.value = parseFloat(this.input.value); - this.syncRange(); + /** Clamps a number to min/max while ensuring it's a valid step interval. */ + private clampAndRoundToStep(value: number) { + const stepPrecision = (String(this.step).split('.')[1] || '').replace(/0+$/g, '').length; + value = Math.round(value / this.step) * this.step; + value = clamp(value, this.min, this.max); + + return parseFloat(value.toFixed(stepPrecision)); + } + + /** Given a value, returns its percentage within a range of min/max. */ + private getPercentageFromValue(value: number) { + return ((value - this.min) / (this.max - this.min)) * 100; + } + + /** Converts coordinates to slider value */ + private getValueFromCoordinates(x: number, y: number) { + const isRtl = this.localize.dir() === 'rtl'; + const isVertical = this.orientation === 'vertical'; + const { top, right, bottom, left, height, width } = this.trackBoundingClientRect; + const pointerPosition = isVertical ? y : x; + const sliderCoords = isVertical + ? { start: top, end: bottom, size: height } + : { start: left, end: right, size: width }; + const relativePosition = isVertical + ? sliderCoords.end - pointerPosition + : isRtl + ? sliderCoords.end - pointerPosition + : pointerPosition - sliderCoords.start; + const percentage = relativePosition / sliderCoords.size; + return this.clampAndRoundToStep(this.min + (this.max - this.min) * percentage); } private handleBlur() { - this.hasTooltip = false; + // Only hide tooltips if neither thumb has focus + if (this.isRange) { + // Allow a subsequent focus event to fire on the other thumb if the user is tabbing + requestAnimationFrame(() => { + const focusedElement = this.shadowRoot?.activeElement; + const thumbHasFocus = focusedElement === this.thumbMin || focusedElement === this.thumbMax; + if (!thumbHasFocus) { + this.hideRangeTooltips(); + } + }); + } else { + this.hideTooltip(); + } + this.customStates.set('focused', false); + this.dispatchEvent(new FocusEvent('blur', { bubbles: true, composed: true })); } - private handleFocus() { - this.hasTooltip = true; + private handleFocus(event: FocusEvent) { + const target = event.target as HTMLElement; + + // Handle focus for specific thumbs in range mode + if (this.isRange) { + if (target === this.thumbMin) { + this.activeThumb = 'min'; + } else if (target === this.thumbMax) { + this.activeThumb = 'max'; + } + this.showRangeTooltips(); + } else { + this.showTooltip(); + } + + this.customStates.set('focused', true); + this.dispatchEvent(new FocusEvent('focus', { bubbles: true, composed: true })); } - @eventOptions({ passive: true }) - private handleThumbDragStart() { - this.hasTooltip = true; - } + private handleKeyDown(event: KeyboardEvent) { + const isRtl = this.localize.dir() === 'rtl'; + const target = event.target as HTMLElement; - private handleThumbDragEnd() { - this.hasTooltip = false; - } + if (this.disabled || this.readonly) return; - private syncProgress(percent: number) { - this.input.style.setProperty('--percent', `${percent * 100}%`); - } + // For range slider, determine which thumb is active + if (this.isRange) { + if (target === this.thumbMin) { + this.activeThumb = 'min'; + } else if (target === this.thumbMax) { + this.activeThumb = 'max'; + } - private syncTooltip(percent: number) { - if (this.output !== null) { - const inputWidth = this.input.offsetWidth; - const tooltipWidth = this.output.offsetWidth; - const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size'); - const isRtl = this.localize.dir() === 'rtl'; - const percentAsWidth = inputWidth * percent; + if (!this.activeThumb) return; + } - // The calculations are used to "guess" where the thumb is located. Since we're using the native range control - // under the hood, we don't have access to the thumb's true coordinates. These measurements can be a pixel or two - // off depending on the size of the control, thumb, and tooltip dimensions. - if (isRtl) { - const x = `${inputWidth - percentAsWidth}px + ${percent} * ${thumbSize}`; - this.output.style.translate = `calc((${x} - ${tooltipWidth / 2}px - ${thumbSize} / 2))`; + // Get current value based on slider mode + const current = this.isRange ? (this.activeThumb === 'min' ? this.minValue : this.maxValue) : this.value; + + let newValue = current; + + // Handle key presses + switch (event.key) { + // Increase + case 'ArrowUp': + case isRtl ? 'ArrowLeft' : 'ArrowRight': + event.preventDefault(); + newValue = this.clampAndRoundToStep(current + this.step); + break; + + // Decrease + case 'ArrowDown': + case isRtl ? 'ArrowRight' : 'ArrowLeft': + event.preventDefault(); + newValue = this.clampAndRoundToStep(current - this.step); + break; + + // Minimum value + case 'Home': + event.preventDefault(); + newValue = this.isRange && this.activeThumb === 'min' ? this.min : this.isRange ? this.minValue : this.min; + break; + + // Maximum value + case 'End': + event.preventDefault(); + newValue = this.isRange && this.activeThumb === 'max' ? this.max : this.isRange ? this.maxValue : this.max; + break; + + // Move up 10% + case 'PageUp': + event.preventDefault(); + const stepUp = Math.max( + current + (this.max - this.min) / 10, + current + this.step, // make sure we at least move up to the next step + ); + newValue = this.clampAndRoundToStep(stepUp); + break; + + // Move down 10% + case 'PageDown': + event.preventDefault(); + const stepDown = Math.min( + current - (this.max - this.min) / 10, + current - this.step, // make sure we at least move down to the previous step + ); + newValue = this.clampAndRoundToStep(stepDown); + break; + + // Handle form submission on Enter + case 'Enter': + submitOnEnter(event, this); + return; + } + + // If no value change, exit early + if (newValue === current) return; + + // Apply the new value with appropriate constraints + if (this.isRange) { + if (this.activeThumb === 'min') { + if (newValue > this.maxValue) { + // If min thumb exceeds max thumb, move both + this.maxValue = newValue; + this.minValue = newValue; + } else { + this.minValue = Math.max(this.min, newValue); + } } else { - const x = `${percentAsWidth}px - ${percent} * ${thumbSize}`; - this.output.style.translate = `calc(${x} - ${tooltipWidth / 2}px + ${thumbSize} / 2)`; + if (newValue < this.minValue) { + // If max thumb goes below min thumb, move both + this.minValue = newValue; + this.maxValue = newValue; + } else { + this.maxValue = Math.min(this.max, newValue); + } + } + this.updateFormValue(); + } else { + this.value = clamp(newValue, this.min, this.max); + } + + // Dispatch events + this.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + + private handleLabelPointerDown(event: PointerEvent) { + event.preventDefault(); + + if (!this.disabled) { + if (this.isRange) { + this.thumbMin?.focus(); + } else { + this.slider.focus(); } } } - @watch('value', { waitUntilFirstUpdate: true }) - handleValueChange() { - // The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to - // min, max, and step properly - this.input.value = this.value.toString(); - this.value = parseFloat(this.input.value); - this.updateValidity(); + private setValueFromCoordinates(x: number, y: number) { + const oldValue = this.value; + this.value = this.getValueFromCoordinates(x, y); - this.syncRange(); - } - - @watch('hasTooltip', { waitUntilFirstUpdate: true }) - syncRange() { - const percent = Math.max(0, (this.value - this.min) / (this.max - this.min)); - - this.syncProgress(percent); - - if (this.tooltip !== 'none') { - // Ensure updates are drawn before we sync the tooltip - this.updateComplete.then(() => this.syncTooltip(percent)); + // Dispatch input events when the value changes by dragging + if (this.value !== oldValue) { + this.dispatchEvent(new InputEvent('input')); } } - /** Sets focus on the slider. */ - focus(options?: FocusOptions) { - this.input.focus(options); + private setThumbValueFromCoordinates(x: number, y: number, thumb: 'min' | 'max') { + const value = this.getValueFromCoordinates(x, y); + const oldValue = thumb === 'min' ? this.minValue : this.maxValue; + + if (thumb === 'min') { + // If min thumb is being dragged and would exceed max thumb + if (value > this.maxValue) { + // Move both thumbs, keeping their distance at 0 + this.maxValue = value; + this.minValue = value; + } else { + // Normal case - just move min thumb + this.minValue = Math.max(this.min, value); + } + } else { + // thumb === 'max' + // If max thumb is being dragged and would go below min thumb + if (value < this.minValue) { + // Move both thumbs, keeping their distance at 0 + this.minValue = value; + this.maxValue = value; + } else { + // Normal case - just move max thumb + this.maxValue = Math.min(this.max, value); + } + } + + // Dispatch input events + if (oldValue !== (thumb === 'min' ? this.minValue : this.maxValue)) { + this.dispatchEvent(new InputEvent('input')); + this.updateFormValue(); + } + } + + private showTooltip() { + if (this.withTooltip && this.tooltip) { + this.tooltip.open = true; + } + } + + private hideTooltip() { + if (this.withTooltip && this.tooltip) { + this.tooltip.open = false; + } + } + + private showRangeTooltips() { + if (this.withTooltip) { + this.tooltips.forEach(tooltip => { + tooltip.open = true; + }); + } + } + + private hideRangeTooltips() { + if (this.withTooltip) { + this.tooltips.forEach(tooltip => { + tooltip.open = false; + }); + } + } + + /** Updates the form value submission for range sliders */ + private updateFormValue() { + if (this.isRange) { + // Submit both values using FormData for range sliders + const formData = new FormData(); + formData.append(this.name || '', String(this.minValue)); + formData.append(this.name || '', String(this.maxValue)); + this.setValue(formData); + } + } + + /** Sets focus to the slider. */ + public focus() { + if (this.isRange) { + this.thumbMin?.focus(); + } else { + this.slider.focus(); + } } /** Removes focus from the slider. */ - blur() { - this.input.blur(); - } - - /** Increments the value of the slider by the value of the step attribute. */ - stepUp() { - this.input.stepUp(); - if (this.value !== Number(this.input.value)) { - this.value = Number(this.input.value); + public blur() { + if (this.isRange) { + if (document.activeElement === this.thumbMin) { + this.thumbMin.blur(); + } else if (document.activeElement === this.thumbMax) { + this.thumbMax.blur(); + } + } else { + this.slider.blur(); } } - /** Decrements the value of the slider by the value of the step attribute. */ - stepDown() { - this.input.stepDown(); - if (this.value !== Number(this.input.value)) { - this.value = Number(this.input.value); + /** + * Decreases the slider's value by `step`. This is a programmatic change, so `input` and `change` events will not be + * emitted when this is called. + */ + public stepDown() { + if (this.isRange) { + // If in range mode, default to stepping down the min value + const newValue = this.clampAndRoundToStep(this.minValue - this.step); + this.minValue = clamp(newValue, this.min, this.maxValue); + this.updateFormValue(); + } else { + const newValue = this.clampAndRoundToStep(this.value - this.step); + this.value = newValue; } } - formResetCallback() { - this.value = this.defaultValue; - - super.formResetCallback(); + /** + * Increases the slider's value by `step`. This is a programmatic change, so `input` and `change` events will not be + * emitted when this is called. + */ + public stepUp() { + if (this.isRange) { + // If in range mode, default to stepping up the max value + const newValue = this.clampAndRoundToStep(this.maxValue + this.step); + this.maxValue = clamp(newValue, this.minValue, this.max); + this.updateFormValue(); + } else { + const newValue = this.clampAndRoundToStep(this.value + this.step); + this.value = newValue; + } } render() { - const hasLabelSlot = this.hasUpdated ? this.hasSlotController.test('label') : this.withLabel; - const hasHintSlot = this.hasUpdated ? this.hasSlotController.test('hint') : this.withHint; - const hasLabel = this.label ? true : !!hasLabelSlot; - const hasHint = this.hint ? true : !!hasHintSlot; + const hasLabel = this.hasSlotController.test('label'); + const hasHint = this.hasSlotController.test('hint'); - // NOTE - always bind value after min/max, otherwise it will be clamped - return html` - ${hasLabel - ? html`` - : ''} + const sliderClasses = classMap({ + small: this.size === 'small', + medium: this.size === 'medium', + large: this.size === 'large', + horizontal: this.orientation === 'horizontal', + vertical: this.orientation === 'vertical', + disabled: this.disabled, + }); -
    - - ${this.tooltip !== 'none' && !this.disabled - ? html` - - ${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value} - - ` - : ''} -
    + // Calculate marker positions + const markers = []; + if (this.withMarkers) { + for (let i = this.min; i <= this.max; i += this.step) { + markers.push(this.getPercentageFromValue(i)); + } + } - ${this.hint} + ${this.label} + + +
    + ${this.hint} +
    `; + + const markersTemplate = this.withMarkers + ? html` +
    + ${markers.map(marker => html``)} +
    + ` + : ''; + + const referencesTemplate = this.withReferences + ? html` + + ` + : ''; + + // Create tooltip template function + const createTooltip = (thumbId: string, value: number) => + this.withTooltip + ? html` + + + + ` + : ''; + + // Render based on mode + if (this.isRange) { + // Range slider mode + const minThumbPosition = clamp(this.getPercentageFromValue(this.minValue), 0, 100); + const maxThumbPosition = clamp(this.getPercentageFromValue(this.maxValue), 0, 100); + + return html` + ${labelAndHint} + +
    +
    +
    + + ${markersTemplate} + + + + +
    + + ${referencesTemplate} +
    + + ${createTooltip('thumb-min', this.minValue)} ${createTooltip('thumb-max', this.maxValue)} + `; + } else { + // Single thumb mode + const thumbPosition = clamp(this.getPercentageFromValue(this.value), 0, 100); + const indicatorOffsetPosition = clamp( + this.getPercentageFromValue(typeof this.indicatorOffset === 'number' ? this.indicatorOffset : this.min), + 0, + 100, + ); + + return html` + ${labelAndHint} + +
    +
    +
    + + ${markersTemplate} + +
    + + ${referencesTemplate} +
    + + ${createTooltip('thumb', this.value)} + `; + } } } diff --git a/packages/webawesome/src/internal/drag.ts b/packages/webawesome/src/internal/drag.ts index 5a22f33d1..0b7ae61e9 100644 --- a/packages/webawesome/src/internal/drag.ts +++ b/packages/webawesome/src/internal/drag.ts @@ -43,3 +43,132 @@ export function drag(container: HTMLElement, options?: Partial) { move(options.initialEvent); } } + +const supportsTouch = typeof window !== 'undefined' && 'ontouchstart' in window; + +/** + * Attaches the necessary events to make an element draggable. + * + * This by itself will not make the element draggable, but it provides the events and callbacks necessary to facilitate + * dragging. Use the `clientX` and `clientY` arguments of each callback to update the UI as desired when dragging. + * + * Drag functionality will be enabled as soon as the constructor is called. A `start()` and `stop()` method can be used + * to start and stop it, if needed. + * + * @usage + * + * const draggable = new DraggableElement(element, { + * start: (clientX, clientY) => { ... }, + * move: (clientX, clientY) => { ... }, + * stop: (clientX, clientY) => { ... } + * }); + */ +export class DraggableElement { + private element: Element; + private isActive = false; + private isDragging = false; + private options: DraggableElementOptions; + + constructor(el: Element, options: Partial) { + this.element = el; + this.options = { + start: () => undefined, + stop: () => undefined, + move: () => undefined, + ...options, + }; + + this.start(); + } + + private handleDragStart = (event: PointerEvent | TouchEvent) => { + const clientX = supportsTouch && 'touches' in event ? event.touches[0].clientX : (event as PointerEvent).clientX; + const clientY = supportsTouch && 'touches' in event ? event.touches[0].clientY : (event as PointerEvent).clientY; + + // Prevent scrolling while dragging + event.preventDefault(); + + if ( + this.isDragging || + // Prevent right-clicks from triggering drags + (!supportsTouch && (event as PointerEvent).buttons > 1) + ) { + return; + } + + this.isDragging = true; + + document.addEventListener('pointerup', this.handleDragStop); + document.addEventListener('pointermove', this.handleDragMove); + document.addEventListener('touchend', this.handleDragStop); + document.addEventListener('touchmove', this.handleDragMove); + this.options.start(clientX, clientY); + }; + + private handleDragStop = (event: PointerEvent | TouchEvent) => { + const clientX = supportsTouch && 'touches' in event ? event.touches[0].clientX : (event as PointerEvent).clientX; + const clientY = supportsTouch && 'touches' in event ? event.touches[0].clientY : (event as PointerEvent).clientY; + + this.isDragging = false; + document.removeEventListener('pointerup', this.handleDragStop); + document.removeEventListener('pointermove', this.handleDragMove); + document.removeEventListener('touchend', this.handleDragStop); + document.removeEventListener('touchmove', this.handleDragMove); + this.options.stop(clientX, clientY); + }; + + private handleDragMove = (event: PointerEvent | TouchEvent) => { + const clientX = supportsTouch && 'touches' in event ? event.touches[0].clientX : (event as PointerEvent).clientX; + const clientY = supportsTouch && 'touches' in event ? event.touches[0].clientY : (event as PointerEvent).clientY; + + // Prevent text selection while dragging + window.getSelection()?.removeAllRanges(); + + this.options.move(clientX, clientY); + }; + + /** Start listening to drags. */ + public start() { + if (!this.isActive) { + this.element.addEventListener('pointerdown', this.handleDragStart); + if (supportsTouch) { + this.element.addEventListener('touchstart', this.handleDragStart); + } + this.isActive = true; + } + } + + /** Stop listening to drags. */ + public stop() { + document.removeEventListener('pointerup', this.handleDragStop); + document.removeEventListener('pointermove', this.handleDragMove); + document.removeEventListener('touchend', this.handleDragStop); + document.removeEventListener('touchmove', this.handleDragMove); + this.element.removeEventListener('pointerdown', this.handleDragStart); + if (supportsTouch) { + this.element.removeEventListener('touchstart', this.handleDragStart); + } + this.isActive = false; + this.isDragging = false; + } + + /** Starts or stops the drag listeners. */ + public toggle(isActive?: boolean) { + const isGoingToBeActive = isActive !== undefined ? isActive : !this.isActive; + + if (isGoingToBeActive) { + this.start(); + } else { + this.stop(); + } + } +} + +export interface DraggableElementOptions { + /** Runs when dragging starts. */ + start: (clientX: number, clientY: number) => void; + /** Runs as the user is dragging. This may execute often, so avoid expensive operations. */ + move: (clientX: number, clientY: number) => void; + /** Runs when dragging ends. */ + stop: (clientX: number, clientY: number) => void; +} diff --git a/packages/webawesome/src/internal/submit-on-enter.ts b/packages/webawesome/src/internal/submit-on-enter.ts new file mode 100644 index 000000000..1f87ccdf8 --- /dev/null +++ b/packages/webawesome/src/internal/submit-on-enter.ts @@ -0,0 +1,64 @@ +import type WaButton from '../components/button/button.js'; +import type { WebAwesomeFormAssociatedElement } from './webawesome-form-associated-element.js'; + +export function submitOnEnter(event: KeyboardEvent, el: T) { + const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + + // Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before + // submitting to allow users to cancel the keydown event if they need to + if (event.key === 'Enter' && !hasModifier) { + // setTimeout in case the event is caught higher up in the tree and defaultPrevented + setTimeout(() => { + // + // When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way + // to check for this is to look at event.isComposing, which will be true when the IME is open. + // + // See https://github.com/shoelace-style/shoelace/pull/988 + // + if (!event.defaultPrevented && !event.isComposing) { + submitForm(el); + } + }); + } +} + +export function submitForm(el: HTMLElement | WebAwesomeFormAssociatedElement) { + let form: HTMLFormElement | null = null; + + if ('form' in el) { + form = el.form as HTMLFormElement | null; + } + + if (!form && 'getForm' in el) { + form = el.getForm(); + } + + if (!form) { + return; + } + + const formElements = [...form.elements]; + + // If we're the only formElement, we submit like a native input. + if (formElements.length === 1) { + form.requestSubmit(null); + return; + } + + const button = formElements.find((el: HTMLButtonElement) => el.type === 'submit' && !el.matches(':disabled')) as + | undefined + | HTMLButtonElement + | WaButton; + + // No button found, don't submit. + if (!button) { + return; + } + + if (['input', 'button'].includes(button.localName)) { + form.requestSubmit(button); + } else { + // requestSubmit() wont work with ``, so trigger a manual click. + button.click(); + } +} diff --git a/packages/webawesome/src/internal/test/form-control-base-tests.ts b/packages/webawesome/src/internal/test/form-control-base-tests.ts index 51c6daa8a..97a91b3a4 100644 --- a/packages/webawesome/src/internal/test/form-control-base-tests.ts +++ b/packages/webawesome/src/internal/test/form-control-base-tests.ts @@ -162,6 +162,7 @@ function runAllValidityTests( const form = await fixture(html``); const control = await createControl(); expect(control.getForm()).to.equal(null); + // control.setAttribute("form", 'test-form'); control.form = 'test-form'; await control.updateComplete; expect(control.getForm()).to.equal(form); diff --git a/packages/webawesome/src/internal/validators/slider-validator.ts b/packages/webawesome/src/internal/validators/slider-validator.ts new file mode 100644 index 000000000..44ea1d0dc --- /dev/null +++ b/packages/webawesome/src/internal/validators/slider-validator.ts @@ -0,0 +1,123 @@ +import type WaSlider from '../../components/slider/slider.js'; +import type { Validator } from '../webawesome-form-associated-element.js'; + +/** + * Comprehensive validator for sliders that handles required, range, and step validation + */ +export const SliderValidator = (): Validator => { + // Create a native range input to get localized validation messages + const nativeRequiredRange = Object.assign(document.createElement('input'), { + type: 'range', + required: true, + }); + + return { + observedAttributes: ['required', 'min', 'max', 'step'], + checkValidity(element) { + const validity: ReturnType = { + message: '', + isValid: true, + invalidKeys: [], + }; + + // Create native range input to get localized validation messages + const createNativeRange = (value: number, min: number, max: number, step: number) => { + const input = document.createElement('input'); + input.type = 'range'; + input.min = String(min); + input.max = String(max); + input.step = String(step); + input.value = String(value); + + // Trigger validation + input.checkValidity(); + return input.validationMessage; + }; + + // Check required validation first + if (element.required && !element.hasInteracted) { + validity.isValid = false; + validity.invalidKeys.push('valueMissing'); + validity.message = nativeRequiredRange.validationMessage || 'Please fill out this field.'; + return validity; + } + + // For range sliders, validate both values + if (element.isRange) { + const minValue = element.minValue; + const maxValue = element.maxValue; + + // Check range underflow for min value + if (minValue < element.min) { + validity.isValid = false; + validity.invalidKeys.push('rangeUnderflow'); + validity.message = + createNativeRange(minValue, element.min, element.max, element.step) || + `Value must be greater than or equal to ${element.min}.`; + return validity; + } + + // Check range overflow for max value + if (maxValue > element.max) { + validity.isValid = false; + validity.invalidKeys.push('rangeOverflow'); + validity.message = + createNativeRange(maxValue, element.min, element.max, element.step) || + `Value must be less than or equal to ${element.max}.`; + return validity; + } + + // Check step mismatch + if (element.step && element.step !== 1) { + const minStepMismatch = (minValue - element.min) % element.step !== 0; + const maxStepMismatch = (maxValue - element.min) % element.step !== 0; + + if (minStepMismatch || maxStepMismatch) { + validity.isValid = false; + validity.invalidKeys.push('stepMismatch'); + const testValue = minStepMismatch ? minValue : maxValue; + validity.message = + createNativeRange(testValue, element.min, element.max, element.step) || + `Value must be a multiple of ${element.step}.`; + return validity; + } + } + } else { + // Single value validation + const value = element.value; + + // Check range underflow + if (value < element.min) { + validity.isValid = false; + validity.invalidKeys.push('rangeUnderflow'); + validity.message = + createNativeRange(value, element.min, element.max, element.step) || + `Value must be greater than or equal to ${element.min}.`; + return validity; + } + + // Check range overflow + if (value > element.max) { + validity.isValid = false; + validity.invalidKeys.push('rangeOverflow'); + validity.message = + createNativeRange(value, element.min, element.max, element.step) || + `Value must be less than or equal to ${element.max}.`; + return validity; + } + + // Check step mismatch + if (element.step && element.step !== 1 && (value - element.min) % element.step !== 0) { + validity.isValid = false; + validity.invalidKeys.push('stepMismatch'); + validity.message = + createNativeRange(value, element.min, element.max, element.step) || + `Value must be a multiple of ${element.step}.`; + return validity; + } + } + + return validity; + }, + }; +}; diff --git a/packages/webawesome/src/styles/themes/active/dimension.css b/packages/webawesome/src/styles/themes/active/dimension.css index f18c7cfb7..793d4950a 100644 --- a/packages/webawesome/src/styles/themes/active/dimension.css +++ b/packages/webawesome/src/styles/themes/active/dimension.css @@ -48,10 +48,10 @@ --box-shadow: inset var(--wa-shadow-s); } } - input[type='range'], - wa-slider, - wa-switch { - --thumb-shadow: var(--wa-theme-active-shadow-pop-out); + + wa-slider::part(thumb), + wa-switch::part(thumb) { + box-shadow: var(--wa-theme-active-shadow-pop-out); } wa-progress-bar { diff --git a/packages/webawesome/src/styles/themes/brutalist/overrides.css b/packages/webawesome/src/styles/themes/brutalist/overrides.css index 4e659fbd4..a27508544 100644 --- a/packages/webawesome/src/styles/themes/brutalist/overrides.css +++ b/packages/webawesome/src/styles/themes/brutalist/overrides.css @@ -45,7 +45,8 @@ wa-carousel::part(pagination-item), wa-comparison::part(handle), wa-progress-bar::part(base), - wa-slider::part(base), + wa-slider::part(track), + wa-slider::part(thumb), input[type='range'], wa-switch::part(control), wa-switch::part(thumb) { diff --git a/packages/webawesome/src/styles/themes/glossy/dimension.css b/packages/webawesome/src/styles/themes/glossy/dimension.css index 8cc603c24..212d4ab51 100644 --- a/packages/webawesome/src/styles/themes/glossy/dimension.css +++ b/packages/webawesome/src/styles/themes/glossy/dimension.css @@ -97,10 +97,9 @@ } } - input[type='range'], - wa-slider, - wa-switch { - --thumb-shadow: + wa-slider::part(thumb), + wa-switch::part(thumb) { + box-shadow: var(--wa-theme-glossy-inner-shine), var(--wa-theme-glossy-top-highlight), var(--wa-theme-glossy-bottom-shadow); } diff --git a/packages/webawesome/src/styles/themes/matter/overrides.css b/packages/webawesome/src/styles/themes/matter/overrides.css index 84dbacb12..43fd05150 100644 --- a/packages/webawesome/src/styles/themes/matter/overrides.css +++ b/packages/webawesome/src/styles/themes/matter/overrides.css @@ -206,9 +206,8 @@ } @media (hover: hover) { - input[type='range']:hover, - wa-slider:hover { - --thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--thumb-color), transparent 85%); + wa-slider:hover::part(thumb) { + box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--wa-form-control-activated-color), transparent 85%); } } diff --git a/packages/webawesome/src/styles/themes/playful/dimension.css b/packages/webawesome/src/styles/themes/playful/dimension.css index 83dc276f1..9e8fa9adb 100644 --- a/packages/webawesome/src/styles/themes/playful/dimension.css +++ b/packages/webawesome/src/styles/themes/playful/dimension.css @@ -110,23 +110,22 @@ ); } - input[type='range'], wa-progress-bar, wa-slider { - --shadow-lower: inset 0 -0.125em 0.5em - oklab(from var(--indicator-color, var(--wa-form-control-activated-color)) calc(l - 0.2) a b); - --shadow-upper: inset 0 0.125em 0.5em - oklab(from var(--indicator-color, var(--wa-form-control-activated-color)) calc(l + 0.4) a b); - - --thumb-shadow: var(--wa-shadow-s), var(--shadow-lower), var(--shadow-upper); - - &::part(indicator) { - box-shadow: var(--shadow-lower), var(--shadow-upper); - } + --shadow-lower: inset 0 -0.125em 0.5em oklab(from var(--wa-form-control-activated-color) calc(l - 0.2) a b); + --shadow-upper: inset 0 0.125em 0.5em oklab(from var(--wa-form-control-activated-color) calc(l + 0.4) a b); } - wa-switch[checked] { - --thumb-shadow: var(--wa-shadow-s); + wa-slider::part(thumb) { + box-shadow: var(--wa-shadow-s), var(--shadow-lower), var(--shadow-upper); + } + + wa-progress-bar::part(indicator) { + box-shadow: var(--shadow-lower), var(--shadow-upper); + } + + wa-switch[checked]::part(thumb) { + box-shadow: var(--wa-shadow-s); } } } diff --git a/packages/webawesome/src/styles/themes/shoelace/overrides.css b/packages/webawesome/src/styles/themes/shoelace/overrides.css index 6cf23234e..9b2b7657c 100644 --- a/packages/webawesome/src/styles/themes/shoelace/overrides.css +++ b/packages/webawesome/src/styles/themes/shoelace/overrides.css @@ -94,9 +94,8 @@ --checked-icon-scale: 0.4; } - input[type='range'], - wa-slider { - --thumb-gap: 0; + wa-slider::part(thumb) { + border: none; } wa-switch {