import { classMap } from "lit/directives/class-map.js"; import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit'; import { HasSlotController } from "../../internal/slot"; import { property } from 'lit/decorators.js'; import ZincElement from '../../internal/zinc-element'; import formControlStyles from '../../form-control.scss'; import styles from './form-group.scss'; /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/form-group * @status experimental * @since 1.0 * * @slot - The default slot. * @slot chip - A chip displayed under the form group's help text. * * @csspart form-control-text - The column holding the label, help text and chip. * * @cssproperty --zn-form-group-sticky-top - Offset the label column sticks at while the inputs scroll past. * */ export default class ZnFormGroup extends ZincElement { static styles: CSSResultGroup = [unsafeCSS(formControlStyles), unsafeCSS(styles)]; private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label', 'chip'); /** * The form group's label. Required for proper accessibility. If you need to display HTML, use the `label` slot * instead. */ @property() label = ''; /** * Text that appears in a tooltip next to the label. If you need to display HTML in the tooltip, use the * `label-tooltip` slot instead. */ @property({ attribute: 'label-tooltip' }) labelTooltip = ''; /** The form groups help text. If you need to display HTML, use the `help-text` slot instead. */ @property({ attribute: 'help-text' }) helpText = ''; @property({ attribute: 'cols', type: Boolean }) forceCols = false; @property({ attribute: 'layout', type: String }) layout = "1,2"; @property({ attribute: 'pad', type: Boolean }) pad: boolean = false; /** The scroller the label is moved against by hand; null while native sticky is enough. */ private tracked: HTMLElement | null = null; /** Set while the compositor is running the movement off a scroll timeline instead. */ private animation: Animation | null = null; private stickyTop: number = 0; private frame: number = 0; private rebind: boolean = false; private offset: number = 0; private resizeObserver: ResizeObserver | null = null; private observedAncestors: Set = new Set(); connectedCallback() { super.connectedCallback(); // Whether an ancestor scrolls depends on how tall this form has grown. Re-measured before paint rather than on the // next frame, or the label is drawn for a frame against the old layout and flickers. this.resizeObserver ??= new ResizeObserver(() => { this.rebind = false; this.bind(); this.positionLabel(); }); this.resizeObserver.observe(this); window.addEventListener('resize', this.onViewportResize); } disconnectedCallback() { super.disconnectedCallback(); this.resizeObserver?.disconnect(); this.observedAncestors.clear(); window.removeEventListener('resize', this.onViewportResize); this.detach(); cancelAnimationFrame(this.frame); this.frame = 0; } protected firstUpdated(changedProperties: PropertyValues) { super.firstUpdated(changedProperties); this.schedule(true); } private get labelColumn(): HTMLElement | null { return this.shadowRoot?.querySelector('.form-control__text') ?? null; } /** Coalesces scroll and resize work into one frame. */ private schedule(rebind: boolean = false) { this.rebind ||= rebind; if (this.frame) return; this.frame = requestAnimationFrame(() => { this.frame = 0; if (this.rebind) { this.rebind = false; this.bind(); } this.positionLabel(); }); } /** * Native sticky only follows the nearest scroll container. Where that container isn't the one * the user actually scrolls — a `zn-panel` body sized to its content inside a scrolling * slideout, say — the label never moves, so it is moved against the real scroller instead. */ private bind() { const column = this.labelColumn; if (!column) return; // Measured with nothing of ours on the label, so what follows reads its resting position. this.detach(); column.style.top = ''; this.stickyTop = parseFloat(getComputedStyle(column).top) || 0; const anchor = this.nearestScrollContainer(column); if (!anchor) return; const scroller = this.scrollingAncestor(column); if (scroller === anchor) return; // A `top` inset against a box that never scrolls has nothing to hold the label back from: // it only pushes the label down the page, so drop it and stand in for sticky below. column.style.top = '0px'; this.observeAncestors(column, scroller); if (!scroller) return; this.tracked = scroller; /* * Scrolling is composited, so a transform written from a scroll handler lands a frame late and the label swims * against the fields. A scroll timeline hands the same movement to the compositor; the handler is the fallback * for browsers without one. */ this.animation = this.scrollLinkedTravel(scroller); if (this.animation) { this.animation.play(); // Content arriving above the form moves the scroll position the label has to start from, and nothing resizes // to say so. Re-measured at the end of a gesture, which is the soonest it can be seen. this.scrollTarget(scroller)?.addEventListener('scrollend', this.onScrollEnd, { passive: true }); return; } this.scrollTarget(scroller)?.addEventListener('scroll', this.onScroll, { passive: true }); } /** * The label's whole journey, as the scroll positions it turns at: still until the fieldset's top reaches the sticky * line, then a pixel for every pixel of scroll until it has crossed the fieldset. `fill: both` holds it at either * end, which is the clamp the scroll handler applies by hand. */ private scrollLinkedTravel(scroller: HTMLElement): Animation | null { const column = this.labelColumn; const fieldset = this.fieldset; if (!column || !fieldset || typeof ScrollTimeline === 'undefined') return null; // The stylesheet drops sticky while the columns are stacked, and there is nothing to follow. if (getComputedStyle(column).position !== 'sticky') return null; if (!('rangeStart' in Animation.prototype)) return null; const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight); if (travel < 1) return null; const start = scroller.scrollTop + column.getBoundingClientRect().top - this.visibleTop(scroller) - this.stickyTop; const animation = new Animation( new KeyframeEffect(column, [ { transform: 'translateY(0px)' }, { transform: `translateY(${travel}px)` } ], { fill: 'both' }), new ScrollTimeline({ source: scroller, axis: 'block' }) ) as Animation & { rangeStart: string; rangeEnd: string }; // In scroll pixels rather than progress, so the scroller growing or shrinking can't shift the label. animation.rangeStart = `${start}px`; animation.rangeEnd = `${start + travel}px`; return animation; } /** Drops everything this component has put on the label or on the scroller. */ private detach() { const target = this.scrollTarget(this.tracked); target?.removeEventListener('scroll', this.onScroll); target?.removeEventListener('scrollend', this.onScrollEnd); this.tracked = null; this.animation?.cancel(); this.animation = null; const column = this.labelColumn; if (!column) return; this.offset = 0; column.style.transform = ''; } /** * The label's travel is measured against the scroller, so content growing elsewhere in it — a sibling group revealing * more fields — leaves the label at a stale position until something re-measures. Any such growth resizes a box * between this group and the scroller. */ private observeAncestors(from: HTMLElement, scroller: HTMLElement | null) { const ancestors = this.ancestors(from); const end = scroller ? ancestors.indexOf(scroller) : -1; const wanted = new Set(ancestors.slice(0, end < 0 ? undefined : end).filter(element => element !== this)); // Observing fires once straight away, so re-observing on every bind would rebind every frame. for (const element of this.observedAncestors) { if (!wanted.has(element)) this.resizeObserver?.unobserve(element); } for (const element of wanted) { if (!this.observedAncestors.has(element)) this.resizeObserver?.observe(element); } this.observedAncestors = wanted; } /** The document scrolls through the window, every other scroller reports its own events. */ private scrollTarget(scroller: HTMLElement | null): EventTarget | null { if (!scroller) return null; return scroller === document.scrollingElement ? window : scroller; } /** Where the scroller's own top edge sits, which for the document is the top of the viewport. */ private visibleTop(scroller: HTMLElement) { return scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top; } private get fieldset(): HTMLElement | null { return this.shadowRoot?.querySelector('.form-control') ?? null; } private readonly onScroll = () => this.schedule(); private readonly onScrollEnd = () => this.schedule(true); // A shorter viewport can make an ancestor scrollable without changing this form's size. private readonly onViewportResize = () => this.schedule(true); private positionLabel() { if (this.animation) return; const column = this.labelColumn; const fieldset = this.fieldset; if (!column || !fieldset) return; let offset = 0; // The stylesheet drops sticky while the columns are stacked; tracking has to stand down too. if (this.tracked && getComputedStyle(column).position === 'sticky') { const visibleTop = this.visibleTop(this.tracked); const restingTop = column.getBoundingClientRect().top - this.offset; const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight); offset = Math.min(Math.max(visibleTop + this.stickyTop - restingTop, 0), travel); } if (Math.round(offset) === Math.round(this.offset)) return; this.offset = offset; column.style.transform = offset ? `translateY(${offset}px)` : ''; } /** The box native sticky would anchor to, whether or not it can be scrolled. */ private nearestScrollContainer(from: HTMLElement): HTMLElement | null { return this.ancestors(from).find(element => { const style = getComputedStyle(element); return this.isScrollContainer(style.overflowY) || this.isScrollContainer(style.overflowX); }) ?? null; } /** The nearest ancestor the user can actually scroll, falling back to the document. */ private scrollingAncestor(from: HTMLElement): HTMLElement | null { const scroller = this.ancestors(from).find(element => { const overflow = getComputedStyle(element).overflowY; return (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay') && element.scrollHeight > element.clientHeight + 1; }); if (scroller) return scroller; const root = document.scrollingElement as HTMLElement | null; return root && root.scrollHeight > root.clientHeight + 1 ? root : null; } private isScrollContainer(overflow: string) { return overflow === 'auto' || overflow === 'scroll' || overflow === 'hidden' || overflow === 'overlay'; } /** Walks the flattened tree, so slots and shadow boundaries are crossed the way layout does. */ private ancestors(from: HTMLElement): HTMLElement[] { const out: HTMLElement[] = []; let node: Node | null = from; while (node) { const parent: Node | null = node instanceof Element && node.assignedSlot ? node.assignedSlot : node.parentNode instanceof ShadowRoot ? node.parentNode.host : node.parentNode; if (parent instanceof HTMLElement) out.push(parent); node = parent; } return out; } render() { const hasLabelSlot = this.hasSlotController.test('label'); const hasLabelTooltipSlot = this.hasSlotController.test('label-tooltip'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); const hasLabel = this.label ? true : hasLabelSlot; const hasLabelTooltip = this.labelTooltip ? true : hasLabelTooltipSlot; const hasHelpText = this.helpText ? true : hasHelpTextSlot; const hasChip = this.hasSlotController.test('chip'); return html`
${hasLabel || hasHelpText || hasChip || this.forceCols ? html`
${hasLabel ? html` ` : html``} ${hasHelpText ? html`
${this.helpText}
` : html``} ${hasChip ? html`
` : html``}
` : html``}
`; } }