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