diff --git a/libs/core/.storybook/preview.js b/libs/core/.storybook/preview.js index 0f01c72cc..a013d0783 100644 --- a/libs/core/.storybook/preview.js +++ b/libs/core/.storybook/preview.js @@ -138,10 +138,29 @@ const withTheme = (StoryFn, context) => { return StoryFn(); }; +// Direction decorator that applies `dir` on the preview iframe only. +// Unlike data-theme, `dir` is interpreted by the browser and must not be set on +// window.parent (that would flip Storybook manager/docs chrome). +// See Guides/RTL and localization for usage. +const withDirection = (StoryFn, context) => { + const direction = context.globals.direction || 'ltr'; + + document.documentElement.setAttribute('dir', direction); + document.body.setAttribute('dir', direction); + + useEffect(() => { + document.documentElement.setAttribute('dir', direction); + document.body.setAttribute('dir', direction); + }, [direction]); + + return StoryFn(); +}; + const preview = { decorators: [ withCustomEventActions, withTheme, + withDirection, ], globalTypes: { @@ -157,10 +176,23 @@ const preview = { dynamicTitle: true, }, }, + direction: { + description: 'Document direction (LTR or RTL)', + toolbar: { + title: 'Direction', + icon: 'transfer', + items: [ + { value: 'ltr', title: 'LTR', right: 'Left-to-right' }, + { value: 'rtl', title: 'RTL', right: 'Right-to-left' }, + ], + dynamicTitle: true, + }, + }, }, initialGlobals: { theme: 'light', + direction: 'ltr', }, parameters: { diff --git a/libs/core/src/components/pds-checkbox/pds-checkbox.scss b/libs/core/src/components/pds-checkbox/pds-checkbox.scss index ba9766585..64a6703a0 100644 --- a/libs/core/src/components/pds-checkbox/pds-checkbox.scss +++ b/libs/core/src/components/pds-checkbox/pds-checkbox.scss @@ -47,10 +47,10 @@ content: ""; display: block; height: 7px; - left: 50%; + inset-block-start: 50%; + inset-inline-start: 50%; position: absolute; - top: 50%; - transform: rotate(90deg) translate3d(-103%, 32%, 0); + transform: translate(-50%, -50%) rotate(90deg); width: 4px; } } @@ -90,10 +90,10 @@ input { content: ""; display: block; height: 7px; - left: 50%; + inset-block-start: 50%; + inset-inline-start: 50%; position: absolute; - top: 50%; - transform: rotate(43deg) translate3d(-110%, -30%, 0); + transform: translate(-50%, -60%) rotate(43deg); width: 4px; } } @@ -126,6 +126,12 @@ input { } } +:host-context([dir='rtl']) { + input:checked::after { + transform: translate(-50%, -60%) rotate(-43deg); + } +} + label { display: flex; gap: var(--pine-dimension-xs); diff --git a/libs/core/src/components/pds-combobox/pds-combobox.scss b/libs/core/src/components/pds-combobox/pds-combobox.scss index d58b12800..379e479a3 100644 --- a/libs/core/src/components/pds-combobox/pds-combobox.scss +++ b/libs/core/src/components/pds-combobox/pds-combobox.scss @@ -70,9 +70,9 @@ .pds-combobox__input-icon { color: var(--pine-color-text-secondary); + inset-inline-end: var(--pine-dimension-150); pointer-events: none; position: absolute; - right: var(--pine-dimension-150); z-index: var(--pine-z-index-raised); } diff --git a/libs/core/src/components/pds-filters/pds-filter/pds-filter.scss b/libs/core/src/components/pds-filters/pds-filter/pds-filter.scss index c0b9cf140..405938671 100644 --- a/libs/core/src/components/pds-filters/pds-filter/pds-filter.scss +++ b/libs/core/src/components/pds-filters/pds-filter/pds-filter.scss @@ -179,7 +179,7 @@ /* Modern browsers: CSS anchor positioning with JavaScript-controlled flipping */ @supports (anchor-name: --test) { inset-block-start: calc(anchor(--filter-trigger bottom) + var(--pine-dimension-100)); - inset-inline-start: anchor(--filter-trigger left); + inset-inline-start: anchor(--filter-trigger start); position: fixed; /* Anchor positioning requires fixed positioning */ /* stylelint-disable-next-line property-no-unknown */ position-anchor: --filter-trigger; @@ -188,8 +188,8 @@ /* Flipping classes applied by JavaScript for precise control */ &.popover-flip-horizontal { - inset-inline-start: anchor(--filter-trigger right); - transform: translateX(-100%); + inset-inline-end: anchor(--filter-trigger end); + inset-inline-start: auto; } &.popover-flip-vertical { @@ -199,14 +199,15 @@ &.popover-flip-horizontal.popover-flip-vertical { inset-block-start: anchor(--filter-trigger top); - inset-inline-start: anchor(--filter-trigger right); - transform: translate(-100%, calc(-100% - var(--pine-dimension-100))); + inset-inline-end: anchor(--filter-trigger end); + inset-inline-start: auto; + transform: translateY(calc(-100% - var(--pine-dimension-100))); } /* Ensure fallback class works with flipping in modern browsers */ &.is-open.popover-flip-horizontal { - inset-inline-start: anchor(--filter-trigger right); - transform: translateX(-100%); + inset-inline-end: anchor(--filter-trigger end); + inset-inline-start: auto; } &.is-open.popover-flip-vertical { @@ -216,8 +217,9 @@ &.is-open.popover-flip-horizontal.popover-flip-vertical { inset-block-start: anchor(--filter-trigger top); - inset-inline-start: anchor(--filter-trigger right); - transform: translate(-100%, calc(-100% - var(--pine-dimension-100))); + inset-inline-end: anchor(--filter-trigger end); + inset-inline-start: auto; + transform: translateY(calc(-100% - var(--pine-dimension-100))); } } diff --git a/libs/core/src/components/pds-filters/pds-filter/pds-filter.tsx b/libs/core/src/components/pds-filters/pds-filter/pds-filter.tsx index 67976ce29..214115490 100644 --- a/libs/core/src/components/pds-filters/pds-filter/pds-filter.tsx +++ b/libs/core/src/components/pds-filters/pds-filter/pds-filter.tsx @@ -1,5 +1,6 @@ import { Component, Element, Event, EventEmitter, Host, h, Prop, State, Method, Listen } from '@stencil/core'; import type { BasePdsProps } from '@utils/interfaces'; +import { isRtlDirection } from '@utils/scroll'; import type { PdsFilterOpenEventDetail, PdsFilterCloseEventDetail, PdsFilterClearEventDetail, PdsFilterVariant } from './filter-interface'; import { enlarge, trash } from '@pine-ds/icons/icons'; @@ -254,16 +255,19 @@ export class PdsFilter implements BasePdsProps { const popoverWidth = 228; const popoverHeight = this.popoverEl.getBoundingClientRect().height || 200; - // Boundary detection for flipping + // Boundary detection for flipping (inline-end overflow respects document direction) const bufferSpace = 20; - const wouldOverflowRight = (triggerRect.left + popoverWidth + bufferSpace) > viewportWidth; + const isRtl = isRtlDirection(this.el); + const wouldOverflowInlineEnd = isRtl + ? (triggerRect.right - popoverWidth - bufferSpace) < 0 + : (triggerRect.left + popoverWidth + bufferSpace) > viewportWidth; const wouldOverflowBottom = (triggerRect.bottom + 8 + popoverHeight + bufferSpace) > viewportHeight; if (supportsAnchorPositioning) { // Modern browsers: CSS anchor positioning + JavaScript-controlled flipping this.popoverEl.classList.remove('popover-flip-horizontal', 'popover-flip-vertical'); - if (wouldOverflowRight) { + if (wouldOverflowInlineEnd) { this.popoverEl.classList.add('popover-flip-horizontal'); } @@ -273,15 +277,15 @@ export class PdsFilter implements BasePdsProps { } else { // Fallback browsers: JavaScript positioning with boundary detection - let left = triggerRect.left; + let left = isRtl ? triggerRect.right - popoverWidth : triggerRect.left; let top = triggerRect.bottom + 8; - let transformOrigin = 'top left'; + let transformOrigin = isRtl ? 'top right' : 'top left'; // Apply horizontal flipping if needed - if (wouldOverflowRight) { + if (wouldOverflowInlineEnd) { const actualPopoverWidth = this.popoverEl.getBoundingClientRect().width || popoverWidth; - left = triggerRect.right - actualPopoverWidth; - transformOrigin = 'top right'; + left = isRtl ? triggerRect.left : triggerRect.right - actualPopoverWidth; + transformOrigin = isRtl ? 'top left' : 'top right'; } // Apply vertical flipping if needed diff --git a/libs/core/src/components/pds-input/pds-input.scss b/libs/core/src/components/pds-input/pds-input.scss index 038a7511e..d2d8d4458 100644 --- a/libs/core/src/components/pds-input/pds-input.scss +++ b/libs/core/src/components/pds-input/pds-input.scss @@ -48,14 +48,14 @@ border-end-start-radius: var(--pds-input-border-radius); border-inline-end: var(--pine-border-width-none); border-start-start-radius: var(--pds-input-border-radius); - left: var(--pds-input-padding-x); + inset-inline-start: var(--pds-input-padding-x); } &::part(suffix) { border-end-end-radius: var(--pds-input-border-radius); border-inline-start: var(--pine-border-width-none); border-start-end-radius: var(--pds-input-border-radius); - right: var(--pds-input-padding-x); + inset-inline-end: var(--pds-input-padding-x); } &::part(prepend), @@ -114,7 +114,9 @@ --pds-select-background: var(--pds-input-addon-background); --pds-select-border: var(--pine-border-width-none); --pds-select-border-radius-end-end: var(--pine-dimension-none); + --pds-select-border-radius-end-start: var(--pds-input-border-radius); --pds-select-border-radius-start-end: var(--pine-dimension-none); + --pds-select-border-radius-start-start: var(--pds-input-border-radius); --pds-select-min-height: calc(var(--pine-dimension-450) - calc(2 * var(--pine-border-width))); } @@ -122,7 +124,9 @@ :host([has-append]) ::slotted(pds-select[slot="append"]) { --pds-select-background: var(--pds-input-addon-background); --pds-select-border: var(--pine-border-width-none); + --pds-select-border-radius-end-end: var(--pds-input-border-radius); --pds-select-border-radius-end-start: var(--pine-dimension-none); + --pds-select-border-radius-start-end: var(--pds-input-border-radius); --pds-select-border-radius-start-start: var(--pine-dimension-none); --pds-select-min-height: calc(var(--pine-dimension-450) - calc(2 * var(--pine-border-width))); } diff --git a/libs/core/src/components/pds-modal/pds-modal.scss b/libs/core/src/components/pds-modal/pds-modal.scss index 0908909b5..d87ebb091 100644 --- a/libs/core/src/components/pds-modal/pds-modal.scss +++ b/libs/core/src/components/pds-modal/pds-modal.scss @@ -13,7 +13,7 @@ opacity: 0; padding: 0; position: fixed; - transition: opacity 0.2s ease, visibility 0.2s ease; + transition: opacity var(--pine-motion-duration-base) var(--pine-motion-easing-in), visibility var(--pine-motion-duration-base) var(--pine-motion-easing-in); visibility: hidden; width: 100%; z-index: var(--pine-z-index-modal); @@ -33,6 +33,7 @@ &.open { opacity: 1; + transition-timing-function: var(--pine-motion-easing-out); visibility: visible; } } diff --git a/libs/core/src/components/pds-select/pds-select.scss b/libs/core/src/components/pds-select/pds-select.scss index d682d3136..cc7249a5a 100644 --- a/libs/core/src/components/pds-select/pds-select.scss +++ b/libs/core/src/components/pds-select/pds-select.scss @@ -58,11 +58,10 @@ select { appearance: none; background-color: var(--pds-select-background); border: var(--pds-select-border); - border-radius: var(--pds-select-border-radius); - border-bottom-left-radius: var(--pds-select-border-radius-end-start, var(--pds-select-border-radius)); - border-bottom-right-radius: var(--pds-select-border-radius-end-end, var(--pds-select-border-radius)); - border-top-left-radius: var(--pds-select-border-radius-start-start, var(--pds-select-border-radius)); - border-top-right-radius: var(--pds-select-border-radius-start-end, var(--pds-select-border-radius)); + border-end-end-radius: var(--pds-select-border-radius-end-end, var(--pds-select-border-radius)); + border-end-start-radius: var(--pds-select-border-radius-end-start, var(--pds-select-border-radius)); + border-start-end-radius: var(--pds-select-border-radius-start-end, var(--pds-select-border-radius)); + border-start-start-radius: var(--pds-select-border-radius-start-start, var(--pds-select-border-radius)); color: var(--pine-color-text); font: var(--pine-typography-body); grid-area: field; @@ -104,6 +103,12 @@ select { } } +:host(.is-disabled) { + .pds-select__select-icon { + color: var(--pine-color-text-disabled); + } +} + :host(.is-invalid) { select { background-color: var(--pine-select-color-background-danger); diff --git a/libs/core/src/components/pds-switch/pds-switch.scss b/libs/core/src/components/pds-switch/pds-switch.scss index 2f4817f6d..22d13436d 100644 --- a/libs/core/src/components/pds-switch/pds-switch.scss +++ b/libs/core/src/components/pds-switch/pds-switch.scss @@ -56,10 +56,10 @@ input { display: block; height: var(--sizing-input-toggle-size); inset-block-start: 50%; - inset-inline-start: 50%; + inset-inline-start: var(--pine-dimension-025); position: absolute; - transform: translate3d(-100%, -50%, 0); - transition: transform var(--number-transition-timing); + transform: translateY(-50%); + transition: inset-inline-start var(--number-transition-timing); width: var(--sizing-input-toggle-size); } } @@ -141,7 +141,7 @@ input:focus-visible:not(:disabled) { // 'Checked' state // switch toggle input:checked::after { - transform: translate3d(0, -50%, 0); + inset-inline-start: calc(100% - var(--sizing-input-toggle-size) - var(--pine-dimension-025)); } // switch container diff --git a/libs/core/src/components/pds-table/pds-table-cell/pds-table-cell.scss b/libs/core/src/components/pds-table/pds-table-cell/pds-table-cell.scss index 3b33c75bc..6f0f7447a 100644 --- a/libs/core/src/components/pds-table/pds-table-cell/pds-table-cell.scss +++ b/libs/core/src/components/pds-table/pds-table-cell/pds-table-cell.scss @@ -20,11 +20,17 @@ :host(.is-fixed) { background: var(--pine-color-background-container); - left: 0; + inset-inline-start: 0; position: sticky; z-index: var(--pine-z-index-raised); } +// Flip shadow toward scrollable content when fixed column is on the inline-end edge. +:host-context([dir='rtl']) { + /* stylelint-disable-next-line pine-design-system/no-hardcoded-colors */ + --box-shadow-fixed: -3px 3px 6px -2px rgba(0, 0, 0, 0.2); +} + // box shadow when table has scrolled and cell is fixed :host(.has-scrolled.is-fixed) { box-shadow: var(--box-shadow-fixed); diff --git a/libs/core/src/components/pds-table/pds-table-cell/pds-table-cell.tsx b/libs/core/src/components/pds-table/pds-table-cell/pds-table-cell.tsx index b508d07e3..69e6fb0a8 100644 --- a/libs/core/src/components/pds-table/pds-table-cell/pds-table-cell.tsx +++ b/libs/core/src/components/pds-table/pds-table-cell/pds-table-cell.tsx @@ -1,4 +1,5 @@ import { Component, Element, Host, Prop, State, Watch, h } from '@stencil/core'; +import { isScrolledFromInlineStart } from '../../../utils/scroll'; import { setupTruncationTooltip } from '../../../utils/truncation-tooltip'; @Component({ @@ -152,7 +153,7 @@ export class PdsTableCell { } try { - this.tableScrolling = this.scrollContainer.scrollLeft > 0; + this.tableScrolling = isScrolledFromInlineStart(this.scrollContainer); } catch (error) { console.warn('Scroll handler error:', error); } diff --git a/libs/core/src/components/pds-table/pds-table-head-cell/pds-table-head-cell.scss b/libs/core/src/components/pds-table/pds-table-head-cell/pds-table-head-cell.scss index 69cba337b..10e849f57 100644 --- a/libs/core/src/components/pds-table/pds-table-head-cell/pds-table-head-cell.scss +++ b/libs/core/src/components/pds-table/pds-table-head-cell/pds-table-head-cell.scss @@ -24,7 +24,7 @@ :host(.is-fixed) { background: var(--pine-color-background-container); - left: var(--pine-dimension-none); + inset-inline-start: var(--pine-dimension-none); position: sticky; z-index: var(--pine-z-index-raised); } @@ -39,6 +39,12 @@ border-block-start: var(--border-head-cell-default); } +// Flip shadow toward scrollable content when fixed column is on the inline-end edge. +:host-context([dir='rtl']) { + /* stylelint-disable-next-line pine-design-system/no-hardcoded-colors */ + --box-shadow-default: -3px 3px 6px -2px rgba(0, 0, 0, 0.1); +} + // box shadow when table has scrolled and cell is fixed :host(.has-scrolled.is-fixed) { box-shadow: var(--box-shadow-default); diff --git a/libs/core/src/components/pds-table/pds-table-head-cell/pds-table-head-cell.tsx b/libs/core/src/components/pds-table/pds-table-head-cell/pds-table-head-cell.tsx index 177276594..7c089aca2 100644 --- a/libs/core/src/components/pds-table/pds-table-head-cell/pds-table-head-cell.tsx +++ b/libs/core/src/components/pds-table/pds-table-head-cell/pds-table-head-cell.tsx @@ -1,6 +1,7 @@ import { Component, Element, Host, Prop, h, Event, EventEmitter, State, Method } from '@stencil/core'; import { downSmall, upSmall } from '@pine-ds/icons/icons'; +import { isScrolledFromInlineStart } from '../../../utils/scroll'; @Component({ tag: 'pds-table-head-cell', @@ -188,7 +189,7 @@ export class PdsTableHeadCell { } try { - this.tableScrolling = this.scrollContainer.scrollLeft > 0; + this.tableScrolling = isScrolledFromInlineStart(this.scrollContainer); } catch (error) { console.warn('Scroll handler error:', error); } diff --git a/libs/core/src/components/pds-table/pds-table.scss b/libs/core/src/components/pds-table/pds-table.scss index 6edd07246..0a5bd0996 100644 --- a/libs/core/src/components/pds-table/pds-table.scss +++ b/libs/core/src/components/pds-table/pds-table.scss @@ -83,15 +83,25 @@ .scroll-shadow-left { /* stylelint-disable-next-line pine-design-system/no-hardcoded-colors */ background: linear-gradient(to right, rgba(0, 0, 0, 0.1), transparent); - border-bottom-left-radius: inherit; - border-top-left-radius: inherit; - left: 0; + border-end-start-radius: inherit; + border-start-start-radius: inherit; + inset-inline-start: 0; +} + +:host-context([dir='rtl']) .scroll-shadow-left { + /* stylelint-disable-next-line pine-design-system/no-hardcoded-colors */ + background: linear-gradient(to left, rgba(0, 0, 0, 0.1), transparent); } .scroll-shadow-right { /* stylelint-disable-next-line pine-design-system/no-hardcoded-colors */ background: linear-gradient(to left, rgba(0, 0, 0, 0.1), transparent); - border-bottom-right-radius: inherit; - border-top-right-radius: inherit; - right: 0; + border-end-end-radius: inherit; + border-start-end-radius: inherit; + inset-inline-end: 0; +} + +:host-context([dir='rtl']) .scroll-shadow-right { + /* stylelint-disable-next-line pine-design-system/no-hardcoded-colors */ + background: linear-gradient(to right, rgba(0, 0, 0, 0.1), transparent); } diff --git a/libs/core/src/components/pds-table/pds-table.tsx b/libs/core/src/components/pds-table/pds-table.tsx index f19408954..6231ddb67 100644 --- a/libs/core/src/components/pds-table/pds-table.tsx +++ b/libs/core/src/components/pds-table/pds-table.tsx @@ -1,4 +1,5 @@ import { Component, Element, Event, EventEmitter, Host, h, Prop, State, Listen } from '@stencil/core'; +import { getScrollOffsetFromStart } from '../../utils/scroll'; @Component({ tag: 'pds-table', @@ -142,7 +143,7 @@ export class PdsTable { * * This method creates a horizontal scrolling system where: * - The table content can scroll horizontally when it exceeds the container width - * - Scroll shadows appear at the left/right edges to indicate scrollable content + * - Scroll shadows appear at the inline-start/end edges to indicate scrollable content * - Fixed columns remain sticky during horizontal scrolling * - Shadows respect border-radius and don't appear when there's nothing to scroll * @@ -165,20 +166,21 @@ export class PdsTable { /** * Updates the visibility of scroll shadows based on current scroll position. - * Left shadow: Shows when scrolled away from start (hidden if fixedColumn is enabled) - * Right shadow: Shows when there's content to scroll and not at the end + * Inline-start shadow: shows when scrolled away from start (hidden if fixedColumn is enabled) + * Inline-end shadow: shows when there is content to scroll and not at the end */ this._responsiveHandleScroll = () => { if (!this.scrollContainer) return; - const scrollLeft = this.scrollContainer.scrollLeft; - const maxScrollLeft = this.scrollContainer.scrollWidth - this.scrollContainer.clientWidth; + const { scrollWidth, clientWidth } = this.scrollContainer; + const maxScroll = scrollWidth - clientWidth; + const scrollOffset = getScrollOffsetFromStart(this.scrollContainer); - // Show left shadow when scrolled away from start, but not if there's a fixed column - leftShadow.style.opacity = (scrollLeft > 0 && !this.fixedColumn) ? '1' : '0'; + // Inline-start shadow when scrolled away from start, but not if there's a fixed column + leftShadow.style.opacity = (scrollOffset > 0 && !this.fixedColumn) ? '1' : '0'; - // Show right shadow only if there's content to scroll AND not at end - rightShadow.style.opacity = (maxScrollLeft > 0 && scrollLeft < maxScrollLeft - 1) ? '1' : '0'; + // Inline-end shadow when there is content to scroll and not at the end + rightShadow.style.opacity = (maxScroll > 0 && scrollOffset < maxScroll - 1) ? '1' : '0'; }; // Add scroll event listener to container element diff --git a/libs/core/src/components/pds-tooltip/pds-tooltip.scss b/libs/core/src/components/pds-tooltip/pds-tooltip.scss index 48e6cd379..e2f6f3922 100644 --- a/libs/core/src/components/pds-tooltip/pds-tooltip.scss +++ b/libs/core/src/components/pds-tooltip/pds-tooltip.scss @@ -17,6 +17,7 @@ max-width: var(--tooltip-dimension-max-width); opacity: 0; padding: var(--pine-dimension-xs) calc(var(--pine-dimension-md) / 2); + position: relative; visibility: hidden; width: max-content; @@ -28,7 +29,7 @@ } .pds-tooltip.pds-tooltip--has-html-content & { - width: auto; + width: fit-content; } &::after { @@ -41,6 +42,7 @@ position: absolute; width: 0; + // Arrow placement uses physical left/right to match Floating UI placement names. .pds-tooltip--right & { border-inline-end-color: var(--pine-color-primary); border-width: var(--tooltip-border-width-arrow-left); diff --git a/libs/core/src/global/styles/_motion.scss b/libs/core/src/global/styles/_motion.scss new file mode 100644 index 000000000..8a5d62302 --- /dev/null +++ b/libs/core/src/global/styles/_motion.scss @@ -0,0 +1,39 @@ +//// +/// Motion tokens (Pine-internal until upstream consolidation) +/// +/// Named duration and easing values for component transitions. +/// +/// Namespace contract: these CSS custom properties use the canonical +/// `--pine-motion-*` namespace that `@kajabi-ui/styles` will own once +/// motion is published upstream. Pine defines them here so component +/// SCSS can adopt the names today; when upstream lands a motion token +/// release, this file is removed in the same PR that bumps the +/// `@kajabi-ui/styles` version. Component SCSS does not change. +/// +/// If upstream ever publishes different values under the same names, +/// remove these fallbacks immediately to avoid divergent definitions. +/// +/// @group pine +//// + +:root { + // Durations + --pine-motion-duration-fast: 120ms; + --pine-motion-duration-base: 200ms; + --pine-motion-duration-slow: 300ms; + + // Easing curves (match the bands documented in Guides/Motion) + --pine-motion-easing-out: cubic-bezier(0, 0, 0.2, 1); + --pine-motion-easing-in: cubic-bezier(0.4, 0, 1, 1); + --pine-motion-easing-in-out: cubic-bezier(0.4, 0, 0.2, 1); +} + +// Honor user preference for reduced motion. Components that opt-in to +// the tokens above inherit the override automatically. +@media (prefers-reduced-motion: reduce) { + :root { + --pine-motion-duration-fast: 0ms; + --pine-motion-duration-base: 0ms; + --pine-motion-duration-slow: 0ms; + } +} diff --git a/libs/core/src/global/styles/app.scss b/libs/core/src/global/styles/app.scss index ea2e870a8..7b3e4b2ff 100644 --- a/libs/core/src/global/styles/app.scss +++ b/libs/core/src/global/styles/app.scss @@ -1,5 +1,6 @@ @use '~@kajabi-ui/styles/dist/pine/pine'; @use 'fonts'; +@use 'motion'; @use '../../components/pds-combobox/pds-combobox-dropdown-panel.scss' as *; // Body-portaled combobox listbox (outside shadow roots; see `dropdown-mount="body"` on pds-combobox) diff --git a/libs/core/src/stories/guides/motion.docs.mdx b/libs/core/src/stories/guides/motion.docs.mdx new file mode 100644 index 000000000..28c861091 --- /dev/null +++ b/libs/core/src/stories/guides/motion.docs.mdx @@ -0,0 +1,54 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Motion + +Motion should reinforce hierarchy and feedback without slowing tasks or causing vestibular discomfort. Pine exposes a small **Pine-internal** motion token set covering durations and easing curves; full publication into the shared design language is a follow-up (see Tokens below). + +## Principles + +1. **Purpose** — Animate only to explain a change of state (open/close, enter/leave, loading, success/error), not for decoration. +2. **Speed** — Prefer short durations (roughly **120–200ms** for micro-interactions, **200–300ms** for overlays). Longer transitions belong to marketing or illustrative surfaces, not dense app chrome. +3. **Easing** — Use **ease-out** for elements entering or gaining focus; **ease-in** for exit; **ease-in-out** for properties that move both ways (for example height on expand/collapse). +4. **Choreography** — Stagger list items sparingly; default to a single coherent transition per layout region. +5. **Respect `prefers-reduced-motion`** — When implementing motion in Pine components or consuming apps, gate non-essential animation behind a media query and provide an instant state change for reduced-motion users. + +## Implementation hints + +- Prefer **CSS transitions** on `opacity`, `transform`, and `visibility` over animating layout-heavy properties (`width`, `height`, `top`) where possible. +- Keep **focus management** paired with motion: if a dialog animates open, focus should move predictably when the animation completes (or immediately when reduced motion applies). +- Coordinate with **design** on shared curves and durations so Stencil, React host apps, and marketing pages do not fight each other. + +## Tokens + +Pine ships a Pine-internal motion token set in `libs/core/src/global/styles/_motion.scss`. Component SCSS should reference the CSS custom properties (not the raw values), so that consolidating these tokens upstream into `@kajabi-ui/styles` is a future token-swap and not a search-and-replace across every component. + +### Durations + +| Token | Default | Use for | +| --- | --- | --- | +| `--pine-motion-duration-fast` | `120ms` | Micro-interactions (hover, focus, small toggles). | +| `--pine-motion-duration-base` | `200ms` | Default for most state changes; the safe pick. | +| `--pine-motion-duration-slow` | `300ms` | Overlays entering or leaving (modal, popover, drawer). | + +### Easing + +| Token | Curve | Use for | +| --- | --- | --- | +| `--pine-motion-easing-out` | `cubic-bezier(0, 0, 0.2, 1)` | Elements **entering** the view or gaining focus. | +| `--pine-motion-easing-in` | `cubic-bezier(0.4, 0, 1, 1)` | Elements **exiting** the view. | +| `--pine-motion-easing-in-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | Properties that move both ways (for example expanding height). | + +### Reduced motion + +The duration tokens collapse to `0ms` under `prefers-reduced-motion: reduce`, so CSS transitions that reference `var(--pine-motion-duration-*)` inherit the system-level override automatically — no extra media-query wrapping is required at the component level. + +**Limitations:** + +- The override only affects duration. Easing tokens still resolve to their normal curves, which is fine for 0ms transitions but worth knowing if you compose tokens elsewhere. +- JavaScript-driven animations (`element.animate`, `requestAnimationFrame` loops) do not read CSS custom properties; you have to check `window.matchMedia('(prefers-reduced-motion: reduce)')` yourself. + +### Roadmap + +When motion tokens are published in `@kajabi-ui/styles` alongside color, spacing, and typography, the CSS variable names above stay the same — the source of truth moves upstream. Track that work in `#ds-support`. diff --git a/libs/core/src/stories/guides/rtl-and-localization.docs.mdx b/libs/core/src/stories/guides/rtl-and-localization.docs.mdx new file mode 100644 index 000000000..5fbed1768 --- /dev/null +++ b/libs/core/src/stories/guides/rtl-and-localization.docs.mdx @@ -0,0 +1,21 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# RTL and localization + +Pine targets **English-first** Kajabi product surfaces today. Components are built to work in RTL when the app sets `dir="rtl"` and passes translated copy from the product i18n layer. Layout styles in Pine SCSS use **CSS logical properties** (`margin-inline`, `padding-inline`, `inset-inline`, `border-*-start/end`, and similar) so mirroring does not require per-locale stylesheets. + +## What teams should assume today + +1. **Document text direction** at the app shell (`dir="rtl"` / `lang`) when you ship RTL. +2. **Prefer tokens and logical properties** in consuming apps for spacing and alignment that should mirror under RTL. Report gaps in Slack (`#ds-support`). +3. **Strings live in the product**, not inside Pine primitives—pass translated `label`, `aria-label`, and slot content from your app’s i18n layer. + +## Verifying in Storybook + +Storybook includes a **Direction** toolbar control (LTR / RTL). Switching it applies `dir` on the preview iframe’s `` and ``, matching the consumer-app contract for RTL surfaces. + +Use the toggle to spot-check components: mirroring, overlay placement, iconography, truncation, keyboard, and screen reader behavior where applicable. + +When you change component layout or positioning, add or update Stencil tests and a short RTL Storybook example where it helps catch regressions in CI. diff --git a/libs/core/src/stories/resources/support.docs.mdx b/libs/core/src/stories/resources/support.docs.mdx index e7a951099..8605370ef 100644 --- a/libs/core/src/stories/resources/support.docs.mdx +++ b/libs/core/src/stories/resources/support.docs.mdx @@ -19,6 +19,8 @@ Our team is here to help! You might find a quick answer by browsing the document Curious about what we're working on? You can check out our Linear board. +For **component lifecycle** labels (stable / beta / deprecated), see Component status. For **RTL and localization** expectations, see RTL and localization. + ## GitHub Our codebase lives on GitHub. Feel free to: diff --git a/libs/core/src/utils/scroll.ts b/libs/core/src/utils/scroll.ts new file mode 100644 index 000000000..087291ccd --- /dev/null +++ b/libs/core/src/utils/scroll.ts @@ -0,0 +1,97 @@ +export type RtlScrollType = 'negative' | 'reverse' | 'default'; + +let cachedRtlScrollType: RtlScrollType | null = null; + +/** + * Detects how this browser reports scrollLeft for RTL overflow containers. + * @see https://github.com/othree/jquery.rtl-scroll-type + */ +export function getRtlScrollType(): RtlScrollType { + if (cachedRtlScrollType) { + return cachedRtlScrollType; + } + + if (typeof document === 'undefined') { + cachedRtlScrollType = 'default'; + return cachedRtlScrollType; + } + + const probe = document.createElement('div'); + probe.style.width = '100px'; + probe.style.height = '100px'; + probe.style.position = 'absolute'; + probe.style.top = '-1000px'; + probe.style.overflow = 'scroll'; + probe.style.direction = 'rtl'; + + const inner = document.createElement('div'); + inner.style.width = '200px'; + inner.style.height = '100px'; + probe.appendChild(inner); + + document.body.appendChild(probe); + + if (probe.scrollLeft > 0) { + cachedRtlScrollType = 'reverse'; + } else { + probe.scrollLeft = 1; + cachedRtlScrollType = probe.scrollLeft === 0 ? 'negative' : 'default'; + } + + document.body.removeChild(probe); + return cachedRtlScrollType; +} + +/** Reset cached RTL scroll type (for tests). */ +export function resetRtlScrollTypeCache(): void { + cachedRtlScrollType = null; +} + +/** True when the element's used text direction is RTL (inherits from ancestors). */ +export function isRtlDirection(element: Element): boolean { + return getComputedStyle(element).direction === 'rtl'; +} + +/** + * RTL scroll offset from inline-start for a known browser scrollLeft model. + * @see https://github.com/othree/jquery.rtl-scroll-type#3-types-of-scrollleft-scrollwidth--100 + */ +export function getRtlScrollOffsetFromStart( + scrollLeft: number, + maxScroll: number, + rtlType: RtlScrollType, +): number { + switch (rtlType) { + case 'negative': + return Math.max(0, -scrollLeft); + case 'reverse': + return Math.max(0, scrollLeft); + case 'default': + default: + return Math.max(0, maxScroll - scrollLeft); + } +} + +/** + * Distance scrolled from the inline-start edge (LTR: left, RTL: right). + */ +export function getScrollOffsetFromStart(container: HTMLElement): number { + const { scrollLeft, scrollWidth, clientWidth } = container; + const maxScroll = Math.max(0, scrollWidth - clientWidth); + + if (maxScroll <= 0) { + return 0; + } + + const isRtl = isRtlDirection(container); + if (!isRtl) { + return scrollLeft; + } + + return getRtlScrollOffsetFromStart(scrollLeft, maxScroll, getRtlScrollType()); +} + +/** True when the container is scrolled away from its inline-start edge. */ +export function isScrolledFromInlineStart(container: HTMLElement): boolean { + return getScrollOffsetFromStart(container) > 0; +} diff --git a/libs/core/src/utils/test/scroll.spec.ts b/libs/core/src/utils/test/scroll.spec.ts new file mode 100644 index 000000000..b6fe5a82e --- /dev/null +++ b/libs/core/src/utils/test/scroll.spec.ts @@ -0,0 +1,66 @@ +import { + getRtlScrollOffsetFromStart, + getRtlScrollType, + getScrollOffsetFromStart, + isScrolledFromInlineStart, + resetRtlScrollTypeCache, +} from '../scroll'; + +describe('scroll utilities', () => { + afterEach(() => { + resetRtlScrollTypeCache(); + }); + + it('returns 0 when there is no overflow', () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'scrollWidth', { value: 100, configurable: true }); + Object.defineProperty(container, 'clientWidth', { value: 100, configurable: true }); + Object.defineProperty(container, 'scrollLeft', { value: 0, writable: true, configurable: true }); + + expect(getScrollOffsetFromStart(container)).toBe(0); + expect(isScrolledFromInlineStart(container)).toBe(false); + }); + + it('uses scrollLeft for LTR containers', () => { + const container = document.createElement('div'); + container.style.direction = 'ltr'; + document.body.appendChild(container); + + Object.defineProperty(container, 'scrollWidth', { value: 200, configurable: true }); + Object.defineProperty(container, 'clientWidth', { value: 100, configurable: true }); + Object.defineProperty(container, 'scrollLeft', { value: 25, writable: true, configurable: true }); + + expect(getScrollOffsetFromStart(container)).toBe(25); + expect(isScrolledFromInlineStart(container)).toBe(true); + + document.body.removeChild(container); + }); + + describe('getRtlScrollOffsetFromStart', () => { + const maxScroll = 100; + + it('default (Blink): rest at scrollLeft=max, offset 0', () => { + expect(getRtlScrollOffsetFromStart(100, maxScroll, 'default')).toBe(0); + expect(getRtlScrollOffsetFromStart(75, maxScroll, 'default')).toBe(25); + expect(getRtlScrollOffsetFromStart(0, maxScroll, 'default')).toBe(100); + }); + + it('reverse (legacy IE/Edge): rest at scrollLeft=0, offset 0', () => { + expect(getRtlScrollOffsetFromStart(0, maxScroll, 'reverse')).toBe(0); + expect(getRtlScrollOffsetFromStart(25, maxScroll, 'reverse')).toBe(25); + expect(getRtlScrollOffsetFromStart(100, maxScroll, 'reverse')).toBe(100); + }); + + it('negative (Firefox): rest at scrollLeft=0, offset increases when negative', () => { + expect(getRtlScrollOffsetFromStart(0, maxScroll, 'negative')).toBe(0); + expect(getRtlScrollOffsetFromStart(-25, maxScroll, 'negative')).toBe(25); + expect(getRtlScrollOffsetFromStart(-100, maxScroll, 'negative')).toBe(100); + }); + }); + + it('detects RTL scroll type with scrollable probe content', () => { + resetRtlScrollTypeCache(); + const type = getRtlScrollType(); + expect(['negative', 'reverse', 'default']).toContain(type); + }); +}); diff --git a/libs/figma/CODE_CONNECT_COVERAGE.md b/libs/figma/CODE_CONNECT_COVERAGE.md new file mode 100644 index 000000000..11f88657b --- /dev/null +++ b/libs/figma/CODE_CONNECT_COVERAGE.md @@ -0,0 +1,53 @@ +# Figma Code Connect coverage + +This file tracks which Pine `pds-*` components have a matching Code Connect file under `libs/figma/`. Update this list when you add or remove mappings. + +## Mapped (Code Connect present) + +| Pine component / pattern | Code Connect file | +| --- | --- | +| `pds-alert` | `components/pds-alert.figma.ts` | +| `pds-avatar` | `components/pds-avatar.figma.ts` | +| `pds-button` | `components/pds-button.figma.ts` | +| `pds-checkbox` | `components/pds-checkbox.figma.ts` | +| `pds-chip` | `components/pds-chip.figma.ts` | +| `pds-divider` | `components/pds-divider.figma.ts` | +| `pds-filter` (subcomponent of filters) | `components/pds-filter.figma.ts` | +| `pds-filters` | `components/pds-filters.figma.ts` | +| `pds-input` | `components/pds-input.figma.ts` | +| `pds-link` | `components/pds-link.figma.ts` | +| `pds-loader` | `components/pds-loader.figma.ts` | +| `pds-modal` | `components/pds-modal.figma.ts` | +| `pds-progress` | `components/pds-progress.figma.ts` | +| `pds-property` | `components/pds-property.figma.ts` | +| `pds-radio` | `components/pds-radio.figma.ts` | +| `pds-radio-group` | `components/pds-radio-group.figma.ts` | +| `pds-select` | `components/pds-select.figma.ts` | +| `pds-switch` | `components/pds-switch.figma.ts` | +| `pds-tabs` | `components/pds-tabs.figma.ts` | +| `pds-textarea` | `components/pds-textarea.figma.ts` | +| `pds-toast` | `components/pds-toast.figma.ts` | +| Pattern: list | `patterns/pds-list.figma.ts` | +| Pattern: page heading | `patterns/pds-page-heading.figma.ts` | + +## Not yet mapped (no Code Connect file) + +Use `figma.config.json` at the repo root (`documentUrlSubstitutions`) when adding a new `.figma.ts` so Figma URLs stay centralized. + +| Pine component | Notes | +| --- | --- | +| `pds-accordion` | Add `components/pds-accordion.figma.ts` when the Figma node is ready. | +| `pds-box` | Layout primitive; may map to layout frames rather than a single component node. | +| `pds-combobox` | | +| `pds-container` | | +| `pds-copytext` | | +| `pds-dropdown-menu` | | +| `pds-icon` | Icon set lives in `@pine-ds/icons`; map if Figma documents a dedicated icon wrapper. | +| `pds-image` | | +| `pds-multiselect` | | +| `pds-popover` | | +| `pds-row` | Often used with `pds-box`; may share layout documentation. | +| `pds-sortable` | Figma substitutions exist for sortable list patterns; wire a `pds-sortable.figma.ts` when aligned. | +| `pds-table` | Composite; consider per-subcomponent mappings (row, cell, head) if needed. | +| `pds-text` | | +| `pds-tooltip` | |