/**
 * @internal
 */
export const PRIMITIVES = Symbol.for('ember-primitives-globals');


---

import { waitForPromise } from '@ember/test-waiters';

import { cell } from 'ember-resources';

const _colorScheme = cell<string | undefined>();

let callbacks: Set<(colorScheme: string) => void> = new Set();

async function runCallbacks(theme: string) {
  await Promise.resolve();

  for (const callback of callbacks.values()) {
    callback(theme);
  }
}

/**
 * Object for managing the color scheme
 */
export const colorScheme = {
  /**
   * Set's the current color scheme to the passed value
   */
  update: (value: string) => {
    colorScheme.current = value;

    void waitForPromise(runCallbacks(value));
  },

  on: {
    /**
     * register a function to be called when the color scheme changes.
     */
    update: (callback: (colorScheme: string) => void) => {
      callbacks.add(callback);
    },
  },
  off: {
    /**
     * unregister a function that would have been called when the color scheme changes.
     */
    update: (callback: (colorScheme: string) => void) => {
      callbacks.delete(callback);
    },
  },

  /**
   * the current valuel of the "color scheme"
   */
  get current(): string | undefined {
    return _colorScheme.current;
  },
  set current(value: string | undefined) {
    _colorScheme.current = value;

    if (!value) {
      localPreference.delete();

      return;
    }

    localPreference.update(value);
    setColorScheme(value);
  },

  get isDark() {
    return _colorScheme.current === 'dark';
  },
  get isLight() {
    return _colorScheme.current !== 'dark';
  },
};

/**
 * Synchronizes state of `colorScheme` with the users preferences as well as reconciles with previously set theme in local storage.
 *
 * This may only be called once per app.
 */
export function sync() {
  /**
   * reset the callbacks
   */
  callbacks = new Set();

  /**
   * If local prefs are set, then we don't care what prefers-color-scheme is
   */
  const userPreference = localPreference.read();

  if (userPreference) {
    setColorScheme(userPreference);
    _colorScheme.current = userPreference;

    return;
  }

  if (prefers.dark()) {
    setColorScheme('dark');
    _colorScheme.current = 'dark';
  } else if (prefers.light()) {
    setColorScheme('light');
    _colorScheme.current = 'light';
  }
}

const queries = {
  dark: window.matchMedia('(prefers-color-scheme: dark)'),
  light: window.matchMedia('(prefers-color-scheme: light)'),
  none: window.matchMedia('(prefers-color-scheme: no-preference)'),
};

queries.dark.addEventListener('change', (e) => {
  if (localPreference.isSet()) return;

  const mode = e.matches ? 'dark' : 'light';

  colorScheme.update(mode);
});

/**
 * Helper methods to determining what the user's preferred color scheme is
 * based on the system preferences rather than the users explicit preference.
 */
export const prefers = {
  dark: () => queries.dark.matches,
  light: () => queries.light.matches,
  none: () => queries.none.matches,
  custom: (name: string) => window.matchMedia(`(prefers-color-scheme: ${name})`).matches,
};

const LOCAL_PREF_KEY = 'ember-primitives/color-scheme#local-preference';

/**
 * Helper methods for working with the color scheme preference in local storage
 */
export const localPreference = {
  isSet: () => Boolean(localPreference.read()),
  read: () => localStorage.getItem(LOCAL_PREF_KEY),
  update: (value: string) => localStorage.setItem(LOCAL_PREF_KEY, value),
  delete: () => localStorage.removeItem(LOCAL_PREF_KEY),
};

/**
 * For the given element, returns the `color-scheme` of that element.
 */
export function getColorScheme(element?: HTMLElement) {
  const style = styleOf(element);

  return style.getPropertyValue('color-scheme');
}

export function setColorScheme(element: HTMLElement, value: string): void;
export function setColorScheme(value: string): void;

export function setColorScheme(...args: [string] | [HTMLElement, string]): void {
  if (typeof args[0] === 'string') {
    styleOf().setProperty('color-scheme', args[0]);

    return;
  }

  if (typeof args[1] === 'string') {
    styleOf(args[0]).setProperty('color-scheme', args[1]);

    return;
  }

  throw new Error(`Invalid arity, expected up to 2 args, received ${args.length}`);
}

/**
 * Removes the `color-scheme` from the given element
 */
export function removeColorScheme(element?: HTMLElement) {
  const style = styleOf(element);

  style.removeProperty('color-scheme');
}

function styleOf(element?: HTMLElement) {
  if (element) {
    return element.style;
  }

  return document.documentElement.style;
}

sync();

window.addEventListener('storage', (e: StorageEvent) => {
  try {
    if (e.key !== LOCAL_PREF_KEY) return;

    // If the key was removed in another tab, fall back to system preference
    if (e.newValue === null) {
      if (prefers.dark()) {
        colorScheme.update('dark');

        return;
      } else if (prefers.light()) {
        colorScheme.update('light');

        return;
      }

      // default to light
      colorScheme.update('light');

      return;
    }

    const newScheme = e.newValue;

    colorScheme.update(newScheme);
  } catch {
    // swallow errors from storage event handling
  }
});


---

import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { assert } from "@ember/debug";

import { isElement } from "./narrowing.ts";
import { createStore } from "./store.ts";

import type { Newable } from "./type-utils";
import type Owner from "@ember/owner";

/**
 * IMPLEMENTATION NOTE:
 *   we don't use https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
 *   because it is not inherently reactive.
 *
 *   Its *event* based, which opts you out of fine-grained reactivity.
 *   We want minimal effort fine-grained reactivity.
 *
 * This Technique follows the DOM tree, and is synchronous,
 * allowing correct fine-grained signals-based reactivity.
 *
 * We *could* do less work to find Providers,
 * but only if we forgoe DOM-tree scoping.
 * We must traverse the DOM hierarchy to validate that we aren't accessing providers from different subtrees.
 */
const LOOKUP = new WeakMap<Text | Element, [unknown, () => unknown]>();

export class Provide<Data extends object> extends Component<{
  /**
   * The Element is not customizable
   * (and also sometimes doesn't exist (depending on the `@element` value))
   */
  Element: null;
  Args: {
    /**
     * What data do you want to provide to the DOM subtree?
     *
     * If this is a function or class, it will be instantiated and given an
     * owner + destroyable linkage via `createStore`
     */
    data: Data | (() => Data) | Newable<Data>;

    /**
     * Optionally, you may use keys to reference the data in the Provide,
     * e.g. when `@data` is an already-created instance and consumers
     * reference it by its class.
     *
     * Keys are compared by identity. String keys are not recommended,
     * because when using a class or other object-like structure,
     * the type in the `<Consume>` component can be derived from that class or object-like structure.
     * With string keys, the `<Consume>` type will be unknown.
     */
    key?: string | object;

    /**
     * Can be used to either customize the element tag ( defaults to div )
     * If set to `false`, we won't use an element for the Provider boundary.
     *
     * Setting this to `false` changes the DOM Node containing the Provider's data to be a text node -- which can be useful when certain CSS situations are needed.
     *
     * But setting to `false` has a hazard: it allows subsequent sibling subtrees to access adjacent providers.
     *
     * There is no way around caveat in library land, and in a framework implementation of context,
     * it can only be solved by having render-tree context implemented, and ignoring DOM
     *  (which then makes the only difference between DOM-Context and Context be whether or not
     *    the context punches through Portals)
     */
    element?: keyof HTMLElementTagNameMap | false | undefined;
  };
  Blocks: {
    /**
     * The content that this component will _provide_ data to the entire hierarchy.
     */
    default: [];
  };
}> {
  get data() {
    assert(`@data is missing in <Provide>. Please pass @data.`, "data" in this.args);

    /**
     * This covers both classes and functions
     */
    if (typeof this.args.data === "function") {
      return createStore<Data>(this, this.args.data);
    }

    /**
     * Non-instantiable value
     */
    return this.args.data;
  }

  element: Text | HTMLElement;

  constructor(
    owner: Owner,
    args: {
      data: Data | (() => Data) | Newable<Data>;
      key?: string;
    },
  ) {
    super(owner, args);

    assert(
      `@element may only be \`false\` or a string (or undefined (default when not set))`,
      this.args.element === undefined ||
        this.args.element === false ||
        typeof this.args.element === "string",
    );

    if (this.useElementProvider) {
      this.element = document.createElement(this.args.element || "div");

      // This tells the browser to ignore everything about this element when it comes to styling
      this.element.style.display = "contents";
    } else {
      this.element = document.createTextNode("");
    }

    const key = this.args.key ?? this.args.data;

    LOOKUP.set(this.element, [key, () => this.data]);
  }

  get useElementProvider() {
    return this.args.element !== false;
  }

  <template>
    {{#if (isElement this.element)}}
      {{this.element}}

      {{#in-element this.element}}
        {{yield}}
      {{/in-element}}

    {{else}}
      {{! NOTE! This type of provider will _allow_ non-descendents using the same key to find the provider and use it.

        For example:
          Provider
            Consumer

          Consumer (finds Provider)
      }}

      {{this.element}}
      {{yield}}

    {{/if}}
  </template>
}

/**
 * How this works:
 * - starting at some deep node (Text, Element, whatever),
 *   start crawling up the ancenstry graph (of DOM Nodes).
 *
 * - This algo "tops out" (since we traverse upwards (otherwise this would be "bottoming out")) at the HTMLDocument (parent of the HTML Tag)
 *
 */
function findForKey<Data>(startElement: Text, key: string | object): undefined | (() => Data) {
  let parent: ParentNode | Text | null | undefined = startElement;

  while (parent) {
    let target: ParentNode | ChildNode | Text | null | undefined = parent;

    while (target) {
      if (!(target instanceof Element) && !(target instanceof Text)) {
        target = target?.previousSibling;
        continue;
      }

      const maybe = LOOKUP.get(target);

      target = target?.previousSibling;

      if (!maybe) {
        continue;
      }

      if (maybe[0] === key) {
        return maybe[1] as () => Data;
      }
    }

    parent = parent.parentElement;
  }
}

type DataForKey<Key> = Key extends string
  ? unknown
  : Key extends Newable<infer T>
    ? T
    : Key extends () => infer T
      ? T
      : Key;

export class Consume<Key extends object | string> extends Component<{
  Args: {
    key: Key;
  };
  Blocks: {
    default: [
      context: {
        data: DataForKey<Key>;
      },
    ];
  };
}> {
  // SAFETY: We do a runtime assert in the getter below.
  @tracked getData!: () => DataForKey<Key>;

  element: Text;

  constructor(owner: Owner, args: { key: Key }) {
    super(owner, args);

    this.element = document.createTextNode("");
  }

  @cached
  get context() {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    const self = this;

    return {
      get data(): DataForKey<Key> {
        const getData = findForKey<Key>(self.element, self.args.key);

        assert(
          `Could not find provided context in <Consume>. Please assure that there is a corresponding <Provide> component before using this <Consume> component`,
          getData,
        );

        // SAFETY: return type handled by getter's signature
        // eslint-disable-next-line @typescript-eslint/no-unsafe-return
        return getData() as any;
      },
    };
  }

  <template>
    {{this.element}}

    {{yield this.context}}
  </template>
}


---

export { FloatingUI } from './floating-ui/component.gts';
export { anchorTo } from './floating-ui/modifier.ts';


---

import type { TOC } from "@ember/component/template-only";

export interface Signature {
  Blocks: {
    /**
     * Content to render in to the `<head>` element
     */
    default: [];
  };
}

function getHead() {
  return document.head;
}

/**
 * Utility component to place elements in the document `<head>`
 *
 * When this component is unrendered, its contents will be removed as well.
 *
 * @example
 * ```js
 * import { InHead } from 'ember-primitives/head';
 *
 * <template>
 *   {{#if @useBootstrap}}
 *     <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
 *     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css">
 *   {{/if}}
 * </template>
 * ```
 */
export const InHead: TOC<Signature> = <template>
  {{#in-element (getHead) insertBefore=null}}
    {{yield}}
  {{/in-element}}
</template>;


---

export { link } from './helpers/link.ts';
export { service } from './helpers/service.ts';


---

/**
 * Returns true if the current frame is within an iframe.
 *
 * ```gjs
 * import { inIframe } from 'ember-primitives/iframe';
 *
 * <template>
 *   {{#if (inFrame)}}
 *     only show content in an iframe
 *   {{/if}}
 * </template>
 * ```
 */
export const inIframe = () => window.self !== window.top;

/**
 * Returns true if the current frame is not within an iframe.
 *
 * ```gjs
 * import { notInIframe } from 'ember-primitives/iframe';
 *
 * <template>
 *   {{#if (notInIframe)}}
 *     only show content when not in an iframe
 *     This is also the default if your site/app
 *     does not use iframes
 *   {{/if}}
 * </template>
 * ```
 */
export const notInIframe = () => !inIframe();


---

/**
 * DANGER: this is a *barrel file*
 *
 * It forces the whole library to be loaded and all dependencies.
 *
 * If you have a small app, you probably don't want to import from here -- instead import from each sub-path.
 */
import { importSync, isDevelopingApp, macroCondition } from '@embroider/macros';

if (macroCondition(isDevelopingApp())) {
  importSync('./components/violations.css');
}

export { Accordion } from './components/accordion.gts';
export type {
  AccordionContentExternalSignature,
  AccordionHeaderExternalSignature,
  AccordionItemExternalSignature,
  AccordionTriggerExternalSignature,
} from './components/accordion/public.ts';
export { Avatar } from './components/avatar.gts';
export { Breadcrumb } from './components/breadcrumb.gts';
export { CommandPalette } from './components/command-palette.gts';
export { Dialog, Modal } from './components/dialog.gts';
export { Drawer } from './components/drawer.gts';
export { ExternalLink } from './components/external-link.gts';
export { Form } from './components/form.gts';
export { IncrementalEach } from './components/incremental-each.gts';
export { Key, KeyCombo } from './components/keys.gts';
export { StickyFooter } from './components/layout/sticky-footer.gts';
export { Link } from './components/link.gts';
export { Menu } from './components/menu.gts';
export { OTP, OTPInput } from './components/one-time-password.gts';
export { Popover } from './components/popover.gts';
export { Portal } from './components/portal.gts';
export { PortalTargets } from './components/portal-targets.gts';
export { TARGETS as PORTALS } from './components/portal-targets.gts';
export { Progress } from './components/progress.gts';
export { Rating } from './components/rating.gts';
export {
  Resizable,
  Handle as ResizableHandle,
  Panel as ResizablePanel,
} from './components/resizable.gts';
export { Scroller } from './components/scroller.gts';
export { Separator } from './components/separator.gts';
export { Shadowed } from './components/shadowed.gts';
export { Slider } from './components/slider.gts';
export { Switch } from './components/switch.gts';
export { Toggle } from './components/toggle.gts';
export { ToggleGroup } from './components/toggle-group.gts';
export { VisuallyHidden } from './components/visually-hidden.gts';
export { Zoetrope } from './components/zoetrope.ts';
export * from './helpers.ts';


---

import { setComponentTemplate } from "@ember/component";
import templateOnly from "@ember/component/template-only";
// Have to use these until min ember version is like 6.3 or something
import { precompileTemplate } from "@ember/template-compilation";

import { getPromiseState } from "reactiveweb/get-promise-state";

import type { ComponentLike } from "@glint/template";

interface LoadSignature<
  Expected = {
    Args: any;
  },
> {
  Blocks: {
    loading: [];
    error: [
      {
        original: unknown;
        reason: string;
      },
    ];
    success?: [component: ComponentLike<Expected>];
  };
}

/**
 * Loads a value / promise / function providing state for the lifetime of that value / promise / function.
 *
 * Can be used for manual bundle splitting via await importing components.
 *
 * @example
 * ```gjs
 * import { load } from 'ember-primitives/load';
 *
 * const Loader = load(() => import('./routes/sub-route.gts'));
 *
 * <template>
 *   <Loader>
 *     <:loading> ... loading ... </:loading>
 *     <:error as |error|> ... error! {{error.reason}} </:error>
 *     <:success as |component|> <component /> </:success>
 *   </Loader>
 * </template>
 * ```
 */
export function load<ExpectedSignature, Value>(
  fn: Value | Promise<Value> | (() => Promise<Value>) | (() => Value),
): ComponentLike<LoadSignature<ExpectedSignature>> {
  return setComponentTemplate(
    precompileTemplate(
      `{{#let (getPromiseState fn) as |state|}}
  {{#if state.isLoading}}
    {{yield to="loading"}}
  {{else if state.error}}
    {{yield state.error to="error"}}
  {{else if state.resolved}}
    {{#if (has-block "success")}}
      {{yield state.resolved to="success"}}
    {{else}}
      <state.component />
    {{/if}}
  {{/if}}
{{/let}}`,
      {
        strictMode: true,
        /**
         * The old setComponentTemplate + precompileTemplate combo
         * does not allow defining things in this scope object,
         * we _have_ to use the shorthand.
         */
        scope: () => ({ fn, getPromiseState }),
      },
    ),
    templateOnly(),
  ) as ComponentLike<LoadSignature<ExpectedSignature>>;
}


---

export function isString(x: unknown): x is string {
  return typeof x === 'string';
}

export function isElement(x: unknown): x is Element {
  return x instanceof Element;
}


---

import { assert } from '@ember/debug';
import { registerDestructor } from '@ember/destroyable';

import Modifier, { type ArgsFor } from 'ember-modifier';

import { resizeObserver } from './resize-observer.ts';

import type Owner from '@ember/owner';

// re-export provided for convenience
export { ignoreROError } from './resize-observer.ts';

export interface Signature {
  /**
   * Any element that is resizable can have onResize attached
   */
  Element: Element;
  Args: {
    Positional: [
      /**
       * The ResizeObserver callback will only receive
       * one entry per resize event.
       *
       * See: [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry)
       */
      callback: (entry: ResizeObserverEntry) => void,
    ];
  };
}

class OnResize extends Modifier<Signature> {
  #callback: ((entry: ResizeObserverEntry) => void) | null = null;
  #element: Element | null = null;

  #resizeObserver = resizeObserver(this);

  constructor(owner: Owner, args: ArgsFor<Signature>) {
    super(owner, args);

    registerDestructor(this, () => {
      if (this.#element && this.#callback) {
        this.#resizeObserver.unobserve(this.#element, this.#callback);
      }
    });
  }

  modify(element: Element, [callback]: [callback: (entry: ResizeObserverEntry) => void]) {
    assert(
      `{{onResize}}: callback must be a function, but was ${callback as unknown as string}`,
      typeof callback === 'function'
    );

    if (this.#element && this.#callback) {
      this.#resizeObserver.unobserve(this.#element, this.#callback);
    }

    this.#resizeObserver.observe(element, callback);

    this.#callback = callback;
    this.#element = element;
  }
}

export const onResize = OnResize;


---

import { assert } from '@ember/debug';
import { registerDestructor } from '@ember/destroyable';
import { getOwner } from '@ember/owner';

import { getAnchor, shouldHandle } from 'should-handle-link';

import type { Newable } from './type-utils.ts';
import type EmberRouter from '@ember/routing/router';
import type RouterService from '@ember/routing/router-service';

export { shouldHandle } from 'should-handle-link';

export interface Options {
  ignore?: string[];
}

export function properLinks(
  options: Options
): <Instance extends object, Klass = { new (...args: any[]): Instance }>(klass: Klass) => Klass;

export function properLinks<Instance extends object, Klass = { new (...args: any[]): Instance }>(
  klass: Klass
): Klass;
/**
 * @internal
 */
export function properLinks<Instance extends object, Klass = { new (...args: any[]): Instance }>(
  options: Options,
  klass: Klass
): Klass;

export function properLinks<Instance extends object, Klass = { new (...args: any[]): Instance }>(
  ...args: [Options] | [Klass] | [Options, Klass]
): Klass | ((klass: Klass) => Klass) {
  let options: Options = {};

  let klass: undefined | Klass = undefined;

  if (args.length === 2) {
    options = args[0];
    klass = args[1];
  } else if (args.length === 1) {
    if (typeof args[0] === 'object') {
      // TODO: how to get first arg type correct?
      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
      return (klass: Klass) => properLinks(args[0] as any, klass);
    } else {
      klass = args[0];
    }
  }

  const ignore = options.ignore || [];

  assert(`klass was not defined. possibile incorrect arity given to properLinks`, klass);

  return class RouterWithProperLinks extends (klass as unknown as Newable<EmberRouter>) {
    // SAFETY: we literally do not care about the args' type here,
    //         because we just call super
    constructor(...args: any[]) {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
      super(...args);

      setup(this, ignore);
    }
  } as unknown as Klass;
}

/**
 * Setup proper links without a decorator.
 * This function only requires that a framework object with an owner is passed.
 */
export function setup(parent: object, ignore?: string[]) {
  const handler = (event: MouseEvent) => {
    /**
     * event.target may not be an anchor,
     * it may be a span, svg, img, or any number of elements nested in <a>...</a>
     */
    const interactive = getAnchor(event);

    if (!interactive) return;

    const owner = getOwner(parent);

    assert('owner is not present', owner);

    const routerService = owner.lookup('service:router');

    handle(routerService, interactive, ignore ?? [], event);
  };

  document.body.addEventListener('click', handler, false);

  registerDestructor(parent, () => document.body.removeEventListener('click', handler));
}

export function handle(
  router: RouterService,
  element: HTMLAnchorElement,
  ignore: string[],
  event: MouseEvent
) {
  if (!shouldHandle(location.href, element, event, ignore)) {
    return;
  }

  const url = new URL(element.href);

  const fullHref = `${url.pathname}${url.search}${url.hash}`;

  const rootURL = router.rootURL;

  let withoutRootURL = fullHref.slice(rootURL.length);

  // re-add the "root" sigil
  // we removed it when we chopped off the rootURL,
  // because the rootURL often has this attached to it as well
  if (!withoutRootURL.startsWith('/')) {
    withoutRootURL = `/${withoutRootURL}`;
  }

  try {
    const routeInfo = router.recognize(fullHref);

    if (routeInfo) {
      event.preventDefault();

      router.transitionTo(withoutRootURL);

      return false;
    }
  } catch (e) {
    if (e instanceof Error && e.name === 'UnrecognizedURLError') {
      return;
    }

    throw e;
  }
}


---

import Helper from '@ember/component/helper';
import { assert } from '@ember/debug';
import { service } from '@ember/service';

import type RouterService from '@ember/routing/router-service';

interface Signature {
  Args: {
    Positional: [string];
  };
  Return: string | undefined;
}

/**
 * Grabs a query-param off the current route from the router service.
 *
 * ```gjs
 * import { qp } from 'ember-primitives/qp';
 *
 * <template>
 *  {{qp "query-param"}}
 * </template>
 * ```
 */
export class qp extends Helper<Signature> {
  @service declare router: RouterService;

  compute([name]: [string]): string | undefined {
    assert('A queryParam name is required', name);

    return this.router.currentRoute?.queryParams?.[name] as string | undefined;
  }
}

/**
 * Returns a string for use as an `href` on `<a>` tags, updated with the passed query param
 *
 * ```gjs
 * import { withQP } from 'ember-primitives/qp';
 *
 * <template>
 *   <a href={{withQP "foo" "2"}}>
 *     ...
 *   </a>
 * </template>
 * ```
 */
export class withQP extends Helper<{ Args: { Positional: [string, string] }; Return: string }> {
  @service declare router: RouterService;

  compute([qpName, nextValue]: [string, string]) {
    const existing = this.router.currentURL;

    assert('A queryParam name is required', qpName);
    assert('There is no currentURL', existing);

    const url = new URL(existing, location.origin);

    url.searchParams.set(qpName, nextValue);

    return url.href;
  }
}

/**
 * Cast a query-param string value to a boolean
 *
 * ```gjs
 * import { castToBoolean, qp } from 'ember-primitives/qp';
 *
 * <template>
 *  {{#if (castToBoolean (qp 'the-qp'))}}
 *    ...
 *  {{/if}}
 * </template>
 * ```
 *
 * The following values are considered "false"
 * - undefined
 * - ""
 * - "0"
 * - false
 * - "f"
 * - "off"
 * - "no"
 * - "null"
 * - "undefined"
 *
 * All other values are considered truthy
 */
export function castToBoolean(x: string | undefined) {
  if (!x) return false;

  const isFalsey =
    x === '0' ||
    x === 'false' ||
    x === 'f' ||
    x === 'null' ||
    x === 'off' ||
    x === 'undefined' ||
    x === 'no';

  if (isFalsey) return false;

  // All other values are considered truthy
  return true;
}


---

import { assert } from '@ember/debug';
import { registerDestructor } from '@ember/destroyable';

import { createStore } from './store.ts';
import { findOwner } from './utils.ts';

/**
 * Creates or returns the ResizeObserverManager.
 *
 * Only one of these will exist per owner.
 *
 * Has only two methods:
 * - observe(element, callback: (resizeObserverEntry) => void)
 * - unobserve(element, callback: (resizeObserverEntry) => void)
 *
 * Like with the underlying ResizeObserver API (and all event listeners),
 * the callback passed to unobserved must be the same reference as the one
 * passed to observe.
 */
export function resizeObserver(context: object) {
  const owner = findOwner(context);

  assert(
    `Could not find owner on the passed context (to resizeObserver). resizeObserver can only be used on an object whos lifetime is in someone entangled with the application (which incidentally has an "owner").`,
    owner
  );

  return createStore(owner, ResizeObserverManager);
}

class ResizeObserverManager {
  #callbacks = new WeakMap<Element, Set<(entry: ResizeObserverEntry) => unknown>>();

  #handleResize = (entries: ResizeObserverEntry[]) => {
    for (const entry of entries) {
      const callbacks = this.#callbacks.get(entry.target);

      if (callbacks) {
        for (const callback of callbacks) {
          callback(entry);
        }
      }
    }
  };
  #observer = new ResizeObserver(this.#handleResize);

  constructor() {
    ignoreROError();

    registerDestructor(this, () => {
      this.#observer?.disconnect();
    });
  }

  /**
   * Initiate the observing of the `element` or add an additional `callback`
   * if the `element` is already observed.
   *
   * @param {object} element
   * @param {function} callback The `callback` is called whenever the size of
   *    the `element` changes. It is called with `ResizeObserverEntry` object
   *    for the particular `element`.
   */
  observe(element: Element, callback: (entry: ResizeObserverEntry) => unknown) {
    const callbacks = this.#callbacks.get(element);

    if (callbacks) {
      callbacks.add(callback);
    } else {
      this.#callbacks.set(element, new Set([callback]));
      this.#observer.observe(element);
    }
  }

  /**
   * End the observing of the `element` or just remove the provided `callback`.
   *
   * It will unobserve the `element` if the `callback` is not provided
   * or there are no more callbacks left for this `element`.
   *
   * @param {object} element
   * @param {function?} callback - The `callback` to remove from the listeners
   *   of the `element` size changes.
   */
  unobserve(element: Element, callback: (entry: ResizeObserverEntry) => unknown) {
    const callbacks = this.#callbacks.get(element);

    if (!callbacks) {
      return;
    }

    callbacks.delete(callback);

    if (!callback || !callbacks.size) {
      this.#callbacks.delete(element);
      this.#observer.unobserve(element);
    }
  }
}

const errorMessages = [
  'ResizeObserver loop limit exceeded',
  'ResizeObserver loop completed with undelivered notifications.',
];

/**
 * Ignores "ResizeObserver loop limit exceeded" error in Ember tests.
 *
 * This "error" is safe to ignore as it is just a warning message,
 * telling that the "looping" observation will be skipped in the current frame,
 * and will be delivered in the next one.
 *
 * For some reason, it is fired as an `error` event at `window` failing Ember
 * tests and exploding Sentry with errors that must be ignored.
 */
export function ignoreROError() {
  if (typeof window.onerror !== 'function') {
    return;
  }

  const onError = window.onerror;

  window.onerror = (...args) => {
    const [message] = args;

    if (typeof message === 'string') {
      if (errorMessages.includes(message)) return true;
    }

    onError(...args);
  };
}


---

import { assert } from '@ember/debug';

import { getPromiseState } from 'reactiveweb/get-promise-state';

import { createStore } from './store.ts';
import { findOwner } from './utils.ts';

import type { Newable } from './type-utils.ts';

/*
import type { Newable } from './type-utils.ts';
import type { Registry } from '@ember/service';
import type Service from '@ember/service';

type Decorator = ReturnType<typeof emberService>;

// export function service<Key extends keyof Registry>(
//   context: object,
//   serviceName: Key
// ): Registry[Key] & Service;
export function service<Class extends object>(
  context: object,
  serviceDefinition: Newable<Class>
): Class;
export function service<Class extends object>(serviceDefinition: Newable<Class>): Decorator;
export function service<Key extends keyof Registry>(serviceName: Key): Decorator;
export function service(prototype: object, name: string | symbol, descriptor: unknown): void;
export function service<Value, Result>(
  context: object,
  fn: Parameters<typeof getPromiseState<Value, Result>>[0]
): ReturnType<typeof getPromiseState<Value, Result>>;
export function service<Value, Result>(
  fn: Parameters<typeof getPromiseState<Value, Result>>[0]
): Decorator;
*/

/**
 * Instantiates a class once per application instance.
 *
 *
 */
export function createService<Instance extends object>(
  context: object,
  theClass: Newable<Instance> | (() => Instance)
): Instance {
  const owner = findOwner(context);

  assert(
    `Could not find owner / application instance. Cannot create a instance tied to the application lifetime without the application`,
    owner
  );

  return createStore(owner, theClass);
}

const promiseCache = new WeakMap<() => any, unknown>();

/**
 * Lazily instantiate a service.
 *
 * This is a replacement / alternative API for ember's `@service` decorator from `@ember/service`.
 *
 * For example
 * ```js
 * import { service } from 'ember-primitives/service';
 *
 * const loader = () => {
 *   let module = await import('./foo/file/with/class.js');
 *   return () => new module.MyState();
 * }
 *
 * class Demo extends Component {
 *   state = createAsyncService(this, loader);
 * }
 * ```
 *
 * The important thing is for repeat usage of `createAsyncService` the second parameter,
 * (loader in this case), must be shared between all usages.
 *
 * This is an alternative to using `createStore` inside an await'd component,
 * or a component rendered with [`getPromiseState`](https://reactive.nullvoxpopuli.com/functions/get-promise-state.getPromiseState.html)
 * ```
 */
export function createAsyncService<Instance extends object>(
  context: object,
  theClass: () => Promise<Newable<Instance> | (() => Instance)>
): ReturnType<typeof getPromiseState<unknown, Instance>> {
  let existing = promiseCache.get(theClass);

  if (!existing) {
    existing = async () => {
      const result = await theClass();

      // Pay no attention to the lies, I don't know what the right type is here
      return createStore(context, result as Newable<Instance>);
    };

    promiseCache.set(theClass, existing);
  }

  // Pay no attention to the TS inference crime here
  return getPromiseState<unknown, Instance>(existing);
}


---

import { link } from 'reactiveweb/link';

import { isNewable } from './utils.ts';

import type { Newable } from './type-utils.ts';

/**
 * context => { class => instance }
 */
const contextCache = new WeakMap<object, Map<object, object>>();

/**
 * Creates a singleton for the given context and links the lifetime of the created class to the passed context
 *
 * Note that this function is _not_ lazy. Calling `createStore` will create an instance of the passed class.
 * When combined with a getter though, creation becomes lazy.
 *
 * In this example, `MyState` is created once per instance of the component.
 * repeat accesses to `this.foo` return a stable reference _as if_ `@cached` were used.
 * ```js
 * class MyState {}
 *
 * class Demo extends Component {
 *   // this is a stable reference
 *   get foo() {
 *     return createStore(this, MyState);
 *   }
 *
 *   // or
 *   bar = createStore(this, MyState);
 *
 *  // or
 *  three = createStore(this, () => new MyState(1, 2));
 * }
 * ```
 *
 * If arguments need to be configured during construction, the second argument may also be a function
 * ```js
 * class MyState {}
 *
 * class Demo extends Component {
 *   // this is a stable reference
 *   get foo() {
 *     return createStore(this, MyState);
 *   }
 * }
 * ```
 */
export function createStore<Instance extends object>(
  context: object,
  theClass: Newable<Instance> | (() => Instance)
): Instance {
  let cache = contextCache.get(context);

  if (!cache) {
    cache = new Map();
    contextCache.set(context, cache);
  }

  let existing = cache.get(theClass);

  if (!existing) {
    const instance = isNewable(theClass) ? new theClass() : theClass();

    link(instance, context);

    cache.set(theClass, instance);
    existing = instance;
  }

  return existing as Instance;
}


---

/**
 * Styles that are always needed, but their components
 * may not be are included here.
 */
import './components/visually-hidden.css';


---

import { registerDestructor } from '@ember/destroyable';

export async function setupTabster(
  /**
   * A destroyable object.
   * This is needed so that when the app (or tests) or unmounted or ending,
   * the tabster instance can be disposed of.
   */
  context: object,
  {
    tabster,
    setTabsterRoot,
  }: {
    /**
     * Let this setup function initalize tabster.
     * https://tabster.io/docs/core
     *
     * This should be done only once per application as we don't want
     * focus managers fighting with each other.
     *
     * Defaults to `true`,
     *
     * Will fallback to an existing tabster instance automatically if `getTabster` returns a value.
     *
     * If `false` is explicitly passed here, you'll also be in charge of teardown.
     */
    tabster?: boolean;
    setTabsterRoot?: boolean;
  } = {}
) {
  const { createTabster, getDeloser, getMover, getTabster, disposeTabster } =
    await import('tabster');

  tabster ??= true;
  setTabsterRoot ??= true;

  if (!tabster) {
    return;
  }

  const existing = getTabster(window);
  const primitivesTabster = existing ?? createTabster(window);

  getMover(primitivesTabster);
  getDeloser(primitivesTabster);

  if (setTabsterRoot) {
    document.body.setAttribute('data-tabster', '{ "root": {} }');
  }

  registerDestructor(context, () => {
    disposeTabster(primitivesTabster);
  });
}


---

// Easily allow apps, which are not yet using strict mode templates, to consume your Glint types, by importing this file.
// Add all your components, helpers and modifiers to the template registry here, so apps don't have to do this.
// See https://typed-ember.gitbook.io/glint/using-glint/ember/authoring-addons

import type { Accordion } from './components/accordion.gts';
import type { AccordionContent } from './components/accordion/content.gts';
import type { AccordionHeader } from './components/accordion/header.gts';
import type { AccordionItem } from './components/accordion/item.gts';
import type { AccordionTrigger } from './components/accordion/trigger.gts';
import type { Dialog } from './components/dialog.gts';
import type { ExternalLink } from './components/external-link.gts';
import type { Link } from './components/link.gts';
import type { Popover } from './components/popover.gts';
import type { Portal } from './components/portal.gts';
import type { PortalTargets } from './components/portal-targets.gts';
import type { Shadowed } from './components/shadowed.gts';
import type { Switch } from './components/switch.gts';
import type { Toggle } from './components/toggle.gts';
import type { service } from './helpers/service.ts';

// import type MyComponent from './components/my-component';

// Remove this once entries have been added! 👇

export default interface Registry {
  // components
  Accordion: typeof Accordion;
  AccordionItem: typeof AccordionItem;
  AccordionHeader: typeof AccordionHeader;
  AccordionContent: typeof AccordionContent;
  AccordionTrigger: typeof AccordionTrigger;
  Dialog: typeof Dialog;
  ExternalLink: typeof ExternalLink;
  Link: typeof Link;
  Popover: typeof Popover;
  PortalTargets: typeof PortalTargets;
  Portal: typeof Portal;
  Shadowed: typeof Shadowed;
  Switch: typeof Switch;
  Toggle: typeof Toggle;

  // helpers
  service: typeof service;
}


---

export { setupTabster } from "./test-support/a11y.ts";
export { findInFirstShadow, findInShadow, findShadow, hasShadowRoot } from "./test-support/dom.ts";
export { fillOTP } from "./test-support/otp.ts";
export { rating } from "./test-support/rating.ts";
export { getRouter, setupRouting } from "./test-support/routing.ts";
export { ZoetropeHelper } from "./test-support/zoetrope.ts";


---

export type Newable<T extends object = object> = { new (...args: any[]): T };


---

import { getOwner } from '@ember/owner';

import type Owner from '@ember/owner';

// this is copy pasted from https://github.com/emberjs/ember.js/blob/60d2e0cddb353aea0d6e36a72fda971010d92355/packages/%40ember/-internals/glimmer/lib/helpers/unique-id.ts
// Unfortunately due to https://github.com/emberjs/ember.js/issues/20165 we cannot use the built-in version in template tags
export function uniqueId(): string {
  // @ts-expect-error this one-liner abuses weird JavaScript semantics that
  // TypeScript (legitimately) doesn't like, but they're nonetheless valid and
  // specced.
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-unsafe-member-access
  return ([3e7] + -1e3 + -4e3 + -2e3 + -1e11).replace(/[0-3]/g, (a) =>
    ((a * 4) ^ ((Math.random() * 16) >> (a & 2))).toString(16)
  );
}

export function isNewable(x: any): x is new (...args: unknown[]) => NonNullable<object> {
  // TypeScript has really bad prototype support -- they don't really
  // want folks using this sort of stuff -- but it's handy for perf and all that.
  //
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
  return x.prototype?.constructor === x;
}

/**
 * Loose check for an "ownerish" API.
 * only the ".lookup" method is required.
 *
 * The requirements for what an "owner" is are sort of undefined,
 * as the actual owner in ember applications has too much on it,
 * and the long term purpose of the owner will be questioned once we
 * eliminate the need to have a registry (what lookup looks in to),
 * but we'll still need "Something" to represent the lifetime of the application.
 *
 * Technically, the owner could be any object, including `{}`
 */
export function isOwner(x: unknown): x is Owner {
  if (!isNonNullableObject(x)) return false;

  return 'lookup' in x && typeof x.lookup === 'function';
}

export function isNonNullableObject(x: unknown): x is NonNullable<object> {
  if (typeof x !== 'object') return false;
  if (x === null) return false;

  return true;
}

/**
 * Can receive the class instance or the owner itself, and will always return return the owner.
 *
 * undefined will be returned if the Owner does not exist on the passed object
 *
 * Can be useful when combined with `createStore` to then create "services",
 * which don't require string lookup.
 */
export function findOwner(contextOrOwner: unknown): Owner | undefined {
  if (isOwner(contextOrOwner)) return contextOrOwner;

  // _ENSURE_ that we have an object, else getOwner makes no sense to call
  if (!isNonNullableObject(contextOrOwner)) return;

  const maybeOwner = getOwner(contextOrOwner);

  if (isOwner(maybeOwner)) return maybeOwner;

  if ('owner' in contextOrOwner) {
    const maybeOwner = contextOrOwner.owner;

    if (isOwner(maybeOwner)) return maybeOwner;
  }

  return;
}


---

export { InViewport, type InViewportSignature } from './viewport/in-viewport.gts';
export { viewport, type ViewportOptions } from './viewport/viewport.ts';


---

import Component from "@glimmer/component";
import { assert } from "@ember/debug";
import { hash } from "@ember/helper";

// temp
//  https://github.com/tracked-tools/tracked-toolbox/issues/38
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { localCopy } from "tracked-toolbox";

import AccordionItem from "./accordion/item.gts";

import type { WithBoundArgs } from "@glint/template";

type AccordionSingleArgs = {
  /**
   * The type of accordion. If `single`, only one item can be selected at a time. If `multiple`, multiple items can be selected at a time.
   */
  type: "single";
  /**
   * Whether the accordion is disabled. When `true`, all items cannot be expanded or collapsed.
   */
  disabled?: boolean;
  /**
   * When type is `single`, whether the accordion is collapsible. When `true`, the selected item can be collapsed by clicking its trigger.
   */
  collapsible?: boolean;
} & (
  | {
      /**
       * The currently selected value. To be used in a controlled fashion in conjunction with `onValueChange`.
       */
      value: string;
      /**
       * A callback that is called when the selected value changes. To be used in a controlled fashion in conjunction with `value`.
       */
      onValueChange: (value: string | undefined) => void;
      /**
       * Not available in a controlled fashion.
       */
      defaultValue?: never;
    }
  | {
      /**
       * Not available in an uncontrolled fashion.
       */
      value?: never;
      /**
       * Not available in an uncontrolled fashion.
       */
      onValueChange?: never;
      /**
       * The default value of the accordion. To be used in an uncontrolled fashion.
       */
      defaultValue?: string;
    }
);

type AccordionMultipleArgs = {
  /**
   * The type of accordion. If `single`, only one item can be selected at a time. If `multiple`, multiple items can be selected at a time.
   */
  type: "multiple";
  /**
   * Whether the accordion is disabled. When `true`, all items cannot be expanded or collapsed.
   */
  disabled?: boolean;
} & (
  | {
      /**
       * The currently selected values. To be used in a controlled fashion in conjunction with `onValueChange`.
       */
      value: string[];
      /**
       * A callback that is called when the selected values change. To be used in a controlled fashion in conjunction with `value`.
       */
      onValueChange: (value?: string[]) => void;
      /**
       * Not available in a controlled fashion.
       */
      defaultValue?: never;
    }
  | {
      /**
       * Not available in an uncontrolled fashion.
       */
      value?: never;
      /**
       * Not available in an uncontrolled fashion.
       */
      onValueChange?: never;
      /**
       * The default values of the accordion. To be used in an uncontrolled fashion.
       */
      defaultValue?: string[];
    }
);

export class Accordion extends Component<{
  Element: HTMLDivElement;
  Args: AccordionSingleArgs | AccordionMultipleArgs;
  Blocks: {
    default: [
      {
        /**
         * The AccordionItem component.
         */
        Item: WithBoundArgs<typeof AccordionItem, "selectedValue" | "toggleItem" | "disabled">;
      },
    ];
  };
}> {
  <template>
    <div data-disabled={{@disabled}} ...attributes>
      {{yield
        (hash
          Item=(component
            AccordionItem
            selectedValue=this.selectedValue
            toggleItem=this.toggleItem
            disabled=@disabled
          )
        )
      }}
    </div>
  </template>

  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
  @localCopy("args.defaultValue") declare _internallyManagedValue?: string | string[];

  get selectedValue() {
    return this.args.value ?? this._internallyManagedValue;
  }

  toggleItem = (value: string) => {
    if (this.args.disabled) {
      return;
    }

    if (this.args.type === "single") {
      this.toggleItemSingle(value);
    } else if (this.args.type === "multiple") {
      this.toggleItemMultiple(value);
    }
  };

  toggleItemSingle = (value: string) => {
    assert("Cannot call `toggleItemSingle` when `disabled` is true.", !this.args.disabled);
    assert(
      "Cannot call `toggleItemSingle` when `type` is not `single`.",
      this.args.type === "single",
    );

    if (value === this.selectedValue && !this.args.collapsible) {
      return;
    }

    const newValue = value === this.selectedValue ? undefined : value;

    if (this.args.onValueChange) {
      this.args.onValueChange(newValue);
    } else {
      this._internallyManagedValue = newValue;
    }
  };

  toggleItemMultiple = (value: string) => {
    assert("Cannot call `toggleItemMultiple` when `disabled` is true.", !this.args.disabled);
    assert(
      "Cannot call `toggleItemMultiple` when `type` is not `multiple`.",
      this.args.type === "multiple",
    );

    const currentValues = (this.selectedValue as string[] | undefined) ?? [];
    const indexOfValue = currentValues.indexOf(value);
    let newValue: string[];

    if (indexOfValue === -1) {
      newValue = [...currentValues, value];
    } else {
      newValue = [
        ...currentValues.slice(0, indexOfValue),
        ...currentValues.slice(indexOfValue + 1),
      ];
    }

    if (this.args.onValueChange) {
      this.args.onValueChange(newValue);
    } else {
      this._internallyManagedValue = newValue;
    }
  };
}

export default Accordion;


---

import { hash } from "@ember/helper";

import { ReactiveImage } from "reactiveweb/image";
import { WaitUntil } from "reactiveweb/wait-until";

import type { TOC } from "@ember/component/template-only";
import type { WithBoundArgs } from "@glint/template";

const Fallback: TOC<{
  Blocks: { default: [] };
  Args: {
    /**
     * The number of milliseconds to wait for the image to load
     * before displaying the fallback
     */
    delayMs?: number;
    /**
     * @private
     * Bound internally by ember-primitives
     */
    isLoaded: boolean;
  };
}> = <template>
  {{#unless @isLoaded}}
    {{#let (WaitUntil @delayMs) as |delayFinished|}}
      {{#if delayFinished}}
        {{yield}}
      {{/if}}
    {{/let}}
  {{/unless}}
</template>;

const Image: TOC<{
  Element: HTMLImageElement;
  Args: {
    /**
     * @private
     * The `src` value for the image.
     *
     * Bound internally by ember-primitives
     */
    src: string;
    /**
     * @private
     * Bound internally by ember-primitives
     */
    isLoaded: boolean;
  };
}> = <template>
  {{#if @isLoaded}}
    <img alt="__missing__" ...attributes src={{@src}} />
  {{/if}}
</template>;

export const Avatar: TOC<{
  Element: HTMLSpanElement;
  Args: {
    /**
     * The `src` value for the image.
     */
    src: string;
  };
  Blocks: {
    default: [
      avatar: {
        /**
         * The image to render. It will only render when it has loaded.
         */
        Image: WithBoundArgs<typeof Image, "src" | "isLoaded">;
        /**
         * An element that renders when the image hasn't loaded.
         * This means whilst it's loading, or if there was an error.
         * If you notice a flash during loading,
         * you can provide a delayMs prop to delay its rendering so it only renders for those with slower connections.
         */
        Fallback: WithBoundArgs<typeof Fallback, "isLoaded">;
        /**
         * true while the image is loading
         */
        isLoading: boolean;
        /**
         * If the image fails to load, this will be `true`
         */
        isError: boolean;
      },
    ];
  };
}> = <template>
  {{#let (ReactiveImage @src) as |imgState|}}
    <span
      data-prim-avatar
      ...attributes
      data-loading={{imgState.isLoading}}
      data-error={{imgState.isError}}
    >
      {{yield
        (hash
          Image=(component Image src=@src isLoaded=imgState.isResolved)
          Fallback=(component Fallback isLoaded=imgState.isResolved)
          isLoading=imgState.isLoading
          isError=imgState.isError
        )
      }}
    </span>
  {{/let}}
</template>;

export default Avatar;


---

import { hash } from "@ember/helper";

import { Separator } from "./separator.gts";

import type { TOC } from "@ember/component/template-only";
import type { WithBoundArgs } from "@glint/template";

export interface Signature {
  Element: HTMLElement;
  Args: {
    /**
     * The accessible label for the breadcrumb navigation.
     * Defaults to "Breadcrumb"
     */
    label?: string;
  };
  Blocks: {
    default: [
      {
        /**
         * A separator component to place between breadcrumb items.
         * Typically renders as "/" or ">" and is decorative (aria-hidden="true").
         * Pre-configured to render as an `<li>` element for proper HTML structure.
         */
        Separator: WithBoundArgs<typeof Separator, "as" | "decorative">;
      },
    ];
  };
}

/**
 * A breadcrumb navigation component that displays the current page's location within a navigational hierarchy.
 *
 * Breadcrumbs help users understand their current location and provide a way to navigate back through the hierarchy.
 *
 * For example:
 *
 * ```gjs live preview
 * import { Breadcrumb } from 'ember-primitives';
 *
 * <template>
 *   <Breadcrumb as |b|>
 *     <li>
 *       <a href="/">Home</a>
 *     </li>
 *     <b.Separator>/</b.Separator>
 *     <li>
 *       <a href="/docs">Docs</a>
 *     </li>
 *     <b.Separator>/</b.Separator>
 *     <li aria-current="page">
 *       Breadcrumb
 *     </li>
 *   </Breadcrumb>
 * </template>
 * ```
 */
export const Breadcrumb: TOC<Signature> = <template>
  <nav aria-label={{if @label @label "Breadcrumb"}} ...attributes>
    <ol>
      {{yield (hash Separator=(component Separator as="li" decorative=true))}}
    </ol>
  </nav>
</template>;

export default Breadcrumb;


---

/**
 * References:
 * - https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
 * - https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-activedescendant
 *
 * A combobox (the input) over a listbox (the results).
 *
 * Focus never leaves the input; `aria-activedescendant` is what moves. This is
 * why there is no tabster mover here: the arrow keys must not move focus, or
 * the user stops being able to type. Tabster still does the finding.
 *
 * Filtering is not this component's job. Render the results you want, in the
 * order you want.
 */
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { assert } from "@ember/debug";
import { registerDestructor } from "@ember/destroyable";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { guidFor } from "@ember/object/internals";

import { modifier as eModifier } from "ember-modifier";
import { getTabster } from "tabster";
// temp
//  https://github.com/tracked-tools/tracked-toolbox/issues/38
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
import { localCopy } from "tracked-toolbox";

import { Link, type Signature as LinkSignature } from "./link.gts";

import type { TOC } from "@ember/component/template-only";
import type Owner from "@ember/owner";
import type { ModifierLike, WithBoundArgs } from "@glint/template";

const OPTION = '[role="option"]';

function isMac() {
  return navigator.userAgent.includes("Mac OS");
}

/**
 * Matches a hotkey description, such as `"mod+k"`, against a keyboard event.
 *
 * `mod` is <kbd>Meta</kbd> on macOS and <kbd>Control</kbd> everywhere else,
 * the same normalization `<KeyCombo>` uses to render one.
 */
function matches(event: KeyboardEvent, hotkey: string) {
  const parts = hotkey
    .toLowerCase()
    .split("+")
    .map((part) => part.trim());
  const key = parts.pop();
  const modifiers = new Set(parts);
  const mod = modifiers.has("mod");

  return (
    event.key.toLowerCase() === key &&
    event.metaKey === (modifiers.has("meta") || (mod && isMac())) &&
    event.ctrlKey === (modifiers.has("ctrl") || (mod && !isMac())) &&
    event.altKey === modifiers.has("alt") &&
    event.shiftKey === modifiers.has("shift")
  );
}

export interface ItemSignature {
  Element: HTMLDivElement;
  Blocks: { default: [] };
}

interface PrivateItemSignature {
  Element: ItemSignature["Element"];
  Args: { activeId: string | undefined };
  Blocks: ItemSignature["Blocks"];
}

class Item extends Component<PrivateItemSignature> {
  id = guidFor(this);

  get isActive() {
    return this.args.activeId === this.id;
  }

  <template>
    <div
      id={{this.id}}
      role="option"
      tabindex="-1"
      aria-selected="{{this.isActive}}"
      data-active="{{this.isActive}}"
      ...attributes
    >
      {{yield}}
    </div>
  </template>
}

export interface LinkItemSignature {
  Element: HTMLAnchorElement;
  Args: LinkSignature["Args"];
  Blocks: { default: [] };
}

interface PrivateLinkItemSignature {
  Element: LinkItemSignature["Element"];
  Args: LinkItemSignature["Args"] & { activeId: string | undefined };
  Blocks: LinkItemSignature["Blocks"];
}

/**
 * An option that is also a link. <kbd>Enter</kbd> dispatches a real click on
 * the anchor, so the router navigates exactly as it would have for a mouse.
 */
class LinkItem extends Component<PrivateLinkItemSignature> {
  id = guidFor(this);

  get isActive() {
    return this.args.activeId === this.id;
  }

  <template>
    <Link
      id={{this.id}}
      role="option"
      tabindex="-1"
      aria-selected="{{this.isActive}}"
      data-active="{{this.isActive}}"
      @href={{@href}}
      @includeActiveQueryParams={{@includeActiveQueryParams}}
      @activeOnSubPaths={{@activeOnSubPaths}}
      ...attributes
    >
      {{yield}}
    </Link>
  </template>
}

export interface ListSignature {
  Element: HTMLDivElement;
  Blocks: {
    default: [
      {
        Item: WithBoundArgs<typeof Item, "activeId">;
        LinkItem: WithBoundArgs<typeof LinkItem, "activeId">;
      },
    ];
  };
}

interface PrivateListSignature {
  Element: ListSignature["Element"];
  Args: {
    id: string;
    register: ModifierLike<{ Element: HTMLElement }>;
    onPointerMove: (event: PointerEvent) => void;
    onClick: (event: MouseEvent) => void;
    Item: ListSignature["Blocks"]["default"][0]["Item"];
    LinkItem: ListSignature["Blocks"]["default"][0]["LinkItem"];
  };
  Blocks: ListSignature["Blocks"];
}

/**
 * The pointer is handled here rather than on each option, and on
 * `pointermove` rather than `pointerenter`, so that an option under a resting
 * cursor re-activates when the cursor moves after the keyboard has activated
 * something else. Native menus behave this way.
 *
 * `:hover` cannot do this job. There is one active option, it is what
 * <kbd>Enter</kbd> chooses, and it is what `aria-activedescendant` reports.
 * Hovering while the keyboard has a different option active would light two
 * rows and tell a screen reader about neither, so the pointer sets the same
 * state the arrow keys do instead of painting its own.
 */
const List: TOC<PrivateListSignature> = <template>
  <div
    id={{@id}}
    role="listbox"
    {{@register}}
    {{on "click" @onClick}}
    {{on "pointermove" @onPointerMove}}
    ...attributes
  >
    {{yield (hash Item=@Item LinkItem=@LinkItem)}}
  </div>
</template>;

export interface InputSignature {
  Element: HTMLInputElement;
}

interface PrivateInputSignature {
  Element: InputSignature["Element"];
  Args: {
    listId: string;
    activeId: string | undefined;
    query: string;
    onInput: (event: Event) => void;
    onKeydown: (event: KeyboardEvent) => void;
  };
}

const Input: TOC<PrivateInputSignature> = <template>
  <input
    type="text"
    role="combobox"
    autocomplete="off"
    autocorrect="off"
    autocapitalize="off"
    spellcheck="false"
    aria-autocomplete="list"
    aria-expanded="true"
    aria-controls={{@listId}}
    aria-activedescendant={{@activeId}}
    value={{@query}}
    {{on "input" @onInput}}
    {{on "keydown" @onKeydown}}
    ...attributes
  />
</template>;

/**
 * One entry in the default layout. A bare string is the label.
 */
export type PaletteItem =
  | string
  | {
      label: string;
      description?: string;
      icon?: string;
    };

const labelOf = (item: PaletteItem) => (typeof item === "string" ? item : item.label);
const descriptionOf = (item: PaletteItem) =>
  typeof item === "string" ? undefined : item.description;
const iconOf = (item: PaletteItem) => (typeof item === "string" ? undefined : item.icon);

/**
 * The default layout: hand it the rows and it renders the whole palette.
 */
export interface ItemsSignature {
  Args: {
    /**
     * The text in the input.
     *
     * The state is managed internally, so this does not need to be a
     * maintained value, but whenever it changes, the input reflects it. Pair
     * it with `@onQueryChange` to keep the query somewhere else, such as a
     * query param.
     */
    query?: string;
    /**
     * Called with the input's text every time it changes.
     */
    onQueryChange?: (query: string) => void;
    /**
     * A key combination that calls `@onOpen` from anywhere on the page, such
     * as `"mod+k"`. `mod` is <kbd>Meta</kbd> on macOS and <kbd>Control</kbd>
     * everywhere else.
     *
     * Needs `@onOpen` to have anything to do. No listener is installed
     * without both.
     */
    hotkey?: string;
    /**
     * Called when `@hotkey` is pressed. Hand it the `open` of whatever the
     * palette is in:
     *
     * ```hbs
     * <CommandPalette @hotkey="mod+k" @onOpen={{d.open}} />
     * ```
     */
    onOpen?: () => void;
    /**
     * The entries to render. Each is a string, or an object with a `label`
     * and optionally a `description` and an `icon`.
     */
    items: PaletteItem[];
    /**
     * Called every time a row is chosen, with the entry that was chosen.
     * This is where a modal palette closes itself:
     *
     * ```hbs
     * <Dialog as |d|>
     *   <CommandPalette @items={{this.commands}} @onSelect={{d.close}} />
     * </Dialog>
     * ```
     *
     * Also where a row's own action goes, since the entry it is handed says
     * which row was chosen.
     */
    onSelect?: (item: PaletteItem, event: Event) => void;
    /**
     * The input's placeholder, and its accessible name.
     *
     * Defaults to "Search".
     */
    placeholder?: string;
  };
  /**
   * No blocks: this form renders the rows. Passing one is an error.
   */
  Blocks: Record<string, never>;
}

/**
 * The composed form: you render the rows.
 */
export interface BlockSignature {
  Args: {
    /**
     * The text in the input.
     *
     * The state is managed internally, so this does not need to be a
     * maintained value, but whenever it changes, the input reflects it. Pair
     * it with `@onQueryChange` to keep the query somewhere else, such as a
     * query param.
     */
    query?: string;
    /**
     * Called with the input's text every time it changes.
     */
    onQueryChange?: (query: string) => void;
    /**
     * A key combination that calls `@onOpen` from anywhere on the page, such
     * as `"mod+k"`. `mod` is <kbd>Meta</kbd> on macOS and <kbd>Control</kbd>
     * everywhere else.
     *
     * Needs `@onOpen` to have anything to do. No listener is installed
     * without both.
     */
    hotkey?: string;
    /**
     * Called when `@hotkey` is pressed. Hand it the `open` of whatever the
     * palette is in:
     *
     * ```hbs
     * <CommandPalette @hotkey="mod+k" @onOpen={{d.open}} />
     * ```
     */
    onOpen?: () => void;
    /**
     * Not for this form: the rows come from the block.
     */
    items?: never;
    /**
     * Not for this form: set it on `Input` yourself.
     */
    placeholder?: never;
    /**
     * Called every time a row is chosen. Hand it `close` to make a modal
     * palette close itself.
     *
     * The rows are yours here, so there is no entry to hand back. Which row
     * was chosen is `event.target.closest("[role=option]")`.
     */
    onSelect?: (event: Event) => void;
  };
  Blocks: {
    default: [
      {
        /**
         * The current text of the input.
         */
        query: string;
        /**
         * Sets the text of the input, for a "clear" button or a suggestion.
         */
        setQuery: (query: string) => void;
        /**
         * The `<input>`, wired as a combobox over `List`.
         */
        Input: WithBoundArgs<
          typeof Input,
          "listId" | "activeId" | "query" | "onInput" | "onKeydown"
        >;
        /**
         * The listbox the rows are rendered into.
         */
        List: WithBoundArgs<
          typeof List,
          "id" | "register" | "onPointerMove" | "onClick" | "Item" | "LinkItem"
        >;
      },
    ];
  };
}

export type Signature = ItemsSignature | BlockSignature;

export class CommandPalette extends Component<Signature> {
  listId = guidFor(this);

  /**
   * Held rather than looked up by id, because `document.getElementById` does
   * not cross into a shadow root. A plain field: it is read when a key is
   * pressed, never while rendering.
   */
  #list: HTMLElement | undefined;

  /**
   * Which row is active, as an id rather than an element or a focus state.
   *
   * This is unusual for this library, where keyboard navigation means tabster
   * moving focus. A combobox cannot do that: focus has to stay in the
   * `<input>` or the reader stops being able to type. So nothing among the
   * rows is ever focused, there is no focus for tabster to track, and what
   * moves instead is `aria-activedescendant` -- which is an id, on the input,
   * pointing at a row. Holding the id is holding exactly what that attribute
   * needs.
   *
   * Tabster still does the finding, in `#find`. This only remembers which of
   * the rows it landed on.
   */
  @tracked activeId: string | undefined;

  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
  @localCopy("args.query") declare _query: string;

  constructor(owner: Owner, args: Signature["Args"]) {
    super(owner, args);

    document.addEventListener("keydown", this.handleHotkey);

    registerDestructor(this, () => {
      document.removeEventListener("keydown", this.handleHotkey);
    });
  }

  get placeholder() {
    return this.args.placeholder ?? "Search";
  }

  /**
   * Whether `@items` was passed, not whether it has anything in it: an empty
   * array is falsy in a template, and a palette whose results have gone away
   * still has to render the input they would be typed into.
   */
  get hasItems() {
    return this.args.items !== undefined;
  }

  /**
   * Each form is called the way its own type describes: with the entry that
   * was chosen, or with the event alone when the rows are the caller's.
   *
   * `@items` is what tells the two apart, and it is `never` on the form that
   * does not take it, so testing it narrows `this.args` to one of them.
   */
  #select(option: HTMLElement, event: Event) {
    if (this.args.items === undefined) {
      this.args.onSelect?.(event);

      return;
    }

    const entry = this.#entryFor(option, this.args.items);

    assert(
      "[BUG] a row of the default layout was chosen, but is not among `@items`",
      entry !== undefined,
    );

    this.args.onSelect?.(entry, event);
  }

  /**
   * `@items` and a block are two ways to say the same thing, and saying both
   * means one of them is being quietly ignored.
   */
  get bothGiven() {
    assert(
      "<CommandPalette> was given both `@items` and a block. Use one: `@items` renders the rows for you, a block renders them yourself.",
      false,
    );

    return "";
  }

  get query() {
    return this._query ?? "";
  }
  set query(value: string) {
    this._query = value;
  }

  registerList = eModifier((element: HTMLElement) => {
    this.#list = element;
  });

  get #activeElement() {
    const { activeId } = this;

    if (!activeId) return undefined;

    // scoped to the listbox, so this works inside a shadow root
    return this.#list?.querySelector<HTMLElement>(`[id="${activeId}"]`) ?? undefined;
  }

  /**
   * The next, previous, first or last option.
   *
   * Tabster does the finding, so hidden and inert options are skipped by the
   * same rules as everything else that moves around the page. It has to be
   * set up by the app, the same way `<Menu>` requires it.
   */
  #find(direction: "next" | "prev" | "first" | "last") {
    const container = this.#list;

    if (!container) return undefined;

    const tabster = getTabster(window);

    assert(
      "<CommandPalette> needs tabster, which the application sets up. " +
        "Call `setupTabster` from 'ember-primitives/tabster' in your application route. " +
        "See https://tabster.io/docs/core",
      tabster,
    );

    const options = { container, includeProgrammaticallyFocusable: true };
    const currentElement = this.#activeElement;
    const { focusable } = tabster;

    if (direction === "first") return focusable.findFirst(options);
    if (direction === "last") return focusable.findLast(options);

    if (!currentElement) {
      return direction === "next" ? focusable.findFirst(options) : focusable.findLast(options);
    }

    const found =
      direction === "next"
        ? focusable.findNext({ ...options, currentElement })
        : focusable.findPrev({ ...options, currentElement });

    // wrap, rather than stop, at either end
    return (
      found ?? (direction === "next" ? focusable.findFirst(options) : focusable.findLast(options))
    );
  }

  #activate(element: HTMLElement | null | undefined) {
    if (!element) return;

    this.activeId = element.id;
    element.scrollIntoView({ block: "nearest" });
  }

  setQuery = (query: string) => {
    this.query = query;
    // the results are about to be somebody else's; whatever was active is not
    this.activeId = undefined;
    this.args.onQueryChange?.(query);
  };

  handleInput = (event: Event) => {
    const { target } = event;

    assert("[BUG] input event without an input", target instanceof HTMLInputElement);

    this.setQuery(target.value);
  };

  handlePointerMove = (event: PointerEvent) => {
    const { target } = event;

    if (!(target instanceof Element)) return;

    const option = target.closest<HTMLElement>(OPTION);

    if (option) {
      this.activeId = option.id;
    }
  };

  handleKeydown = (event: KeyboardEvent) => {
    // mid-composition (IME), the arrow keys belong to the candidate window
    if (event.isComposing) return;

    switch (event.key) {
      case "ArrowDown": {
        event.preventDefault();
        this.#activate(this.#find("next"));

        return;
      }
      case "ArrowUp": {
        event.preventDefault();
        this.#activate(this.#find("prev"));

        return;
      }
      case "Enter": {
        // nothing arrowed yet chooses the first result, so a reader can type
        // and press Enter without leaving the keys they were already on
        const active = this.#activeElement ?? this.#find("first");

        if (!active) return;

        event.preventDefault();
        // a real click, so one handler covers the mouse and the keyboard, and
        // an anchor navigates the way the browser would have
        active.click();

        return;
      }
      /**
       * Home and End are left to the browser: in an editable combobox they
       * move the caret, which is what the APG asks for.
       */
    }
  };

  handleHotkey = (event: KeyboardEvent) => {
    const { hotkey, onOpen } = this.args;

    if (!hotkey || !onOpen) return;
    if (!matches(event, hotkey)) return;

    event.preventDefault();
    onOpen();
  };

  /**
   * The entry a row came from, for the default layout. Rows are rendered one
   * per entry and in order, so a row's position among the options is its
   * entry's position in `@items`. A block form has no entries, and gets
   * `undefined`.
   */
  #entryFor(option: HTMLElement, items: PaletteItem[]) {
    const options = this.#list?.querySelectorAll<HTMLElement>(OPTION);

    for (let i = 0; i < (options?.length ?? 0); i++) {
      if (options?.[i] === option) return items[i];
    }

    return undefined;
  }

  /**
   * Choosing is delegated, so a row is markup rather than a listener, and
   * `@onSelect` is reached the same way however the rows were rendered.
   */
  handleClick = (event: MouseEvent) => {
    const { target } = event;

    if (!(target instanceof Element)) return;

    const option = target.closest<HTMLElement>(OPTION);

    if (!option) return;

    /**
     * A modified click on a link opens it somewhere else and leaves the
     * reader where they are, so the palette stays where they left it.
     */
    if (option.closest("a")) {
      if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
      if (event.button !== 0) return;
    }

    this.#select(option, event);
  };

  <template>
    {{#let
      (component
        Input
        listId=this.listId
        activeId=this.activeId
        query=this.query
        onInput=this.handleInput
        onKeydown=this.handleKeydown
      )
      (component
        List
        id=this.listId
        register=this.registerList
        onPointerMove=this.handlePointerMove
        onClick=this.handleClick
        Item=(component Item activeId=this.activeId)
        LinkItem=(component LinkItem activeId=this.activeId)
      )
      as |PaletteInput PaletteList|
    }}
      {{#if this.hasItems}}
        {{#if (has-block)}}{{this.bothGiven}}{{/if}}

        <PaletteInput
          class="ember-primitives__command-palette__input"
          placeholder={{this.placeholder}}
          aria-label={{this.placeholder}}
        />

        <PaletteList class="ember-primitives__command-palette__list" as |l|>
          {{#each @items as |item|}}
            <l.Item class="ember-primitives__command-palette__item">
              {{#if (iconOf item)}}
                <span class="ember-primitives__command-palette__icon">{{iconOf item}}</span>
              {{/if}}
              <span class="ember-primitives__command-palette__label">{{labelOf item}}</span>
              {{#if (descriptionOf item)}}
                <span class="ember-primitives__command-palette__description">{{descriptionOf
                    item
                  }}</span>
              {{/if}}
            </l.Item>
          {{/each}}
        </PaletteList>
      {{else}}
        {{yield (hash query=this.query setQuery=this.setQuery Input=PaletteInput List=PaletteList)}}
      {{/if}}
    {{/let}}
  </template>
}

export default CommandPalette;


---

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { assert } from "@ember/debug";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";

import { modifier as eModifier } from "ember-modifier";
// temp
//  https://github.com/tracked-tools/tracked-toolbox/issues/38
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
import { localCopy } from "tracked-toolbox";

import type { TOC } from "@ember/component/template-only";
import type { ModifierLike, WithBoundArgs } from "@glint/template";

const DialogElement: TOC<{
  Element: HTMLDialogElement;
  Args: {
    /**
     * @internal
     */
    open: boolean | undefined;
    /**
     * @internal
     */
    onClose: () => void;

    /**
     * @internal
     */
    register: ModifierLike<{ Element: HTMLDialogElement }>;
  };
  Blocks: { default: [] };
}> = <template>
  <dialog ...attributes open={{@open}} {{on "close" @onClose}} {{@register}}>
    {{yield}}
  </dialog>
</template>;

export interface Signature {
  Args: {
    /**
     * Optionally set the open state of the `<dialog>`
     * The state will still be managed internally,
     * so this does not need to be a maintained value, but whenever it changes,
     * the dialog element will reflect that change accordingly.
     */
    open?: boolean;
    /**
     * When the `<dialog>` is closed, this function will be called
     * and the `<dialog>`'s `returnValue` will be passed.
     *
     * This can be used to determine which button was clicked to close the modal
     *
     * Note though that this value is only populated when using
     * `<form method='dialog'>`
     */
    onClose?: (returnValue: string) => void;
  };
  Blocks: {
    default: [
      {
        /**
         * Represents the open state of the `<dialog>` element.
         */
        isOpen: boolean;

        /**
         * Closes the `<dialog>` element
         * Will throw an error if `Dialog` is not rendered.
         */
        close: () => void;

        /**
         * Opens the `<dialog>` element.
         * Will throw an error if `Dialog` is not rendered.
         */
        open: () => void;

        /**
         * This modifier should be applied to the button that opens the Dialog so that it can be re-focused when the dialog closes.
         *
         * Example:
         *
         * ```gjs
         * <template>
         *   <Modal as |m|>
         *     <button {{m.focusOnClose}} {{on "click" m.open}}>Open</button>
         *
         *     <m.Dialog>...</m.Dialog>
         *   </Modal>
         * </template>
         * ```
         */
        focusOnClose: ModifierLike<{ Element: HTMLElement }>;

        /**
         * This is the `<dialog>` element (with some defaults pre-wired).
         * This is required to be rendered.
         */
        Dialog: WithBoundArgs<typeof DialogElement, "onClose" | "register" | "open">;
      },
    ];
  };
}

class ModalDialog extends Component<Signature> {
  <template>
    {{yield
      (hash
        isOpen=this.isOpen
        open=this.open
        close=this.close
        focusOnClose=this.refocus
        Dialog=(component DialogElement open=@open onClose=this.handleClose register=this.register)
      )
    }}
  </template>

  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
  @localCopy("args.open") declare _isOpen: boolean;

  get isOpen() {
    /**
     * Always fallback to false (closed)
     */
    return this._isOpen ?? false;
  }
  set isOpen(val: boolean) {
    this._isOpen = val;
  }

  #lastIsOpen = false;
  refocus = eModifier((element) => {
    assert(`focusOnClose is only valid on a HTMLElement`, element instanceof HTMLElement);

    if (!this.isOpen && this.#lastIsOpen) {
      element.focus();
    }

    this.#lastIsOpen = this.isOpen;
  });

  @tracked declare dialogElement: HTMLDialogElement | undefined;

  register = eModifier((element: HTMLDialogElement) => {
    /**
     * This is very sad.
     *
     * But we need the element to be 'root state'
     * so that when we read things like "isOpen",
     * when the dialog is finally rendered, all the
     * downstream properties render.
     *
     * This has to be an async / delayed a bit, so that
     * the tracking frame can exit, and we don't infinite loop
     */
    void (async () => {
      await Promise.resolve();

      this.dialogElement = element;
    })();
  });

  /**
   * Closes the dialog -- this will throw an error in development if the dialog element was not rendered
   */
  close = () => {
    assert(
      "Cannot call `close` on <Dialog> without rendering the dialog element.",
      this.dialogElement,
    );

    /**
     * If the element is already closed, don't run all this again
     */
    if (!this.dialogElement.hasAttribute("open")) {
      return;
    }

    /**
     * removes the `open` attribute
     * handleClose will be called because the dialog has bound the `close` event.
     */
    this.dialogElement.close();
  };

  /**
   * @internal
   *
   * handles the <dialog> element's native close behavior.
   * listened to via addEventListener('close', ...);
   */
  handleClose = () => {
    assert(
      "Cannot call `handleDialogClose` on <Dialog> without rendering the dialog element. This is likely a bug in ember-primitives. Please open an issue <3",
      this.dialogElement,
    );

    this.isOpen = false;
    this.args.onClose?.(this.dialogElement.returnValue);
    // the return value ends up staying... which is annoying
    this.dialogElement.returnValue = "";
  };

  /**
   * Opens the dialog -- this will throw an error in development if the dialog element was not rendered
   */
  open = () => {
    assert(
      "Cannot call `open` on <Dialog> without rendering the dialog element.",
      this.dialogElement,
    );

    /**
     * If the element is already open, don't run all this again
     */
    if (this.dialogElement.hasAttribute("open")) {
      return;
    }

    /**
     * adds the `open` attribute
     */
    this.dialogElement.showModal();
    this.isOpen = true;
  };
}

export interface SimpleSignature {
  Element: HTMLDialogElement;
  Blocks: {
    default: [
      {
        /**
         * Opens the dialog, modally.
         */
        open: () => void;
        /**
         * Closes the dialog.
         */
        close: () => void;
      },
    ];
  };
}

/**
 * A modal `<dialog>` around whatever you put in it, and the two things needed
 * to drive it.
 *
 * ```gjs
 * <Dialog as |d|>
 *   <CommandPalette @onSelect={{d.close}} @onOpen={{d.open}} @hotkey="mod+k" />
 * </Dialog>
 * ```
 *
 * The element is here, so a trigger cannot be: whatever opens this either
 * lives inside it, or is a key combination. For a dialog opened by a button
 * beside it, use `<Modal>`, which hands you the element to place.
 *
 * <kbd>Escape</kbd> closes it and focus returns to whatever opened it, which
 * is the `<dialog>` element's own behaviour. Set `closedby` to change which
 * actions dismiss it.
 */
export class Dialog extends Component<SimpleSignature> {
  /**
   * A plain field rather than tracked state: it is read when somebody calls
   * `open` or `close`, never while rendering, so nothing has to settle in a
   * second pass.
   */
  #element: HTMLDialogElement | undefined;

  register = eModifier((element: HTMLDialogElement) => {
    this.#element = element;

    return () => {
      this.#element = undefined;
    };
  });

  /**
   * `showModal` on an open dialog, and `close` on a closed one, are both
   * no-ops per spec, so neither needs guarding here.
   */
  open = () => {
    this.#element?.showModal();
  };

  close = () => {
    this.#element?.close();
  };

  <template>
    <dialog {{this.register}} ...attributes>
      {{yield (hash open=this.open close=this.close)}}
    </dialog>
  </template>
}

export const Modal = ModalDialog;

export default ModalDialog;


---

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { assert } from "@ember/debug";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";

import { modifier as eModifier } from "ember-modifier";
// temp
//  https://github.com/tracked-tools/tracked-toolbox/issues/38
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
import { localCopy } from "tracked-toolbox";

import type { TOC } from "@ember/component/template-only";
import type { ModifierLike, WithBoundArgs } from "@glint/template";

const DrawerElement: TOC<{
  Element: HTMLDialogElement;
  Args: {
    /**
     * @internal
     */
    open: boolean | undefined;
    /**
     * @internal
     */
    onClose: () => void;

    /**
     * @internal
     */
    register: ModifierLike<{ Element: HTMLDialogElement }>;
  };
  Blocks: { default: [] };
}> = <template>
  <dialog ...attributes open={{@open}} {{on "close" @onClose}} {{@register}}>
    {{yield}}
  </dialog>
</template>;

export interface Signature {
  Args: {
    /**
     * Optionally set the open state of the drawer
     * The state will still be managed internally,
     * so this does not need to be a maintained value, but whenever it changes,
     * the drawer element will reflect that change accordingly.
     */
    open?: boolean;
    /**
     * When the drawer is closed, this function will be called
     * and the drawer's `returnValue` will be passed.
     *
     * This can be used to determine which button was clicked to close the drawer
     *
     * Note though that this value is only populated when using
     * `<form method='dialog'>`
     */
    onClose?: (returnValue: string) => void;
  };
  Blocks: {
    default: [
      {
        /**
         * Represents the open state of the drawer element.
         */
        isOpen: boolean;

        /**
         * Closes the drawer element
         * Will throw an error if `Drawer` is not rendered.
         */
        close: () => void;

        /**
         * Opens the drawer element.
         * Will throw an error if `Drawer` is not rendered.
         */
        open: () => void;

        /**
         * This modifier should be applied to the button that opens the Drawer so that it can be re-focused when the drawer closes.
         *
         * Example:
         *
         * ```gjs
         * <template>
         *   <Drawer as |d|>
         *     <button {{d.focusOnClose}} {{on "click" d.open}}>Open</button>
         *
         *     <d.Drawer>...</d.Drawer>
         *   </Drawer>
         * </template>
         * ```
         */
        focusOnClose: ModifierLike<{ Element: HTMLElement }>;

        /**
         * This is the `<dialog>` element (with some defaults pre-wired).
         * This is required to be rendered.
         */
        Drawer: WithBoundArgs<typeof DrawerElement, "onClose" | "register" | "open">;
      },
    ];
  };
}

class DrawerDialog extends Component<Signature> {
  <template>
    {{yield
      (hash
        isOpen=this.isOpen
        open=this.open
        close=this.close
        focusOnClose=this.refocus
        Drawer=(component DrawerElement open=@open onClose=this.handleClose register=this.register)
      )
    }}
  </template>

  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
  @localCopy("args.open") declare _isOpen: boolean;

  get isOpen() {
    /**
     * Always fallback to false (closed)
     */
    return this._isOpen ?? false;
  }
  set isOpen(val: boolean) {
    this._isOpen = val;
  }

  #lastIsOpen = false;
  refocus = eModifier((element) => {
    assert(`focusOnClose is only valid on a HTMLElement`, element instanceof HTMLElement);

    if (!this.isOpen && this.#lastIsOpen) {
      element.focus();
    }

    this.#lastIsOpen = this.isOpen;
  });

  @tracked declare drawerElement: HTMLDialogElement | undefined;

  register = eModifier((element: HTMLDialogElement) => {
    /**
     * This is very sad.
     *
     * But we need the element to be 'root state'
     * so that when we read things like "isOpen",
     * when the drawer is finally rendered, all the
     * downstream properties render.
     *
     * This has to be an async / delayed a bit, so that
     * the tracking frame can exit, and we don't infinite loop
     */
    void (async () => {
      await Promise.resolve();

      this.drawerElement = element;
    })();
  });

  /**
   * Closes the drawer -- this will throw an error in development if the drawer element was not rendered
   */
  close = () => {
    assert(
      "Cannot call `close` on <Drawer> without rendering the drawer element.",
      this.drawerElement,
    );

    /**
     * If the element is already closed, don't run all this again
     */
    if (!this.drawerElement.hasAttribute("open")) {
      return;
    }

    /**
     * removes the `open` attribute
     * handleClose will be called because the drawer has bound the `close` event.
     */
    this.drawerElement.close();
  };

  /**
   * @internal
   *
   * handles the <dialog> element's native close behavior.
   * listened to via addEventListener('close', ...);
   */
  handleClose = () => {
    assert(
      "Cannot call `handleClose` on <Drawer> without rendering the drawer element. This is likely a bug in ember-primitives. Please open an issue <3",
      this.drawerElement,
    );

    this.isOpen = false;
    this.args.onClose?.(this.drawerElement.returnValue);
    // the return value ends up staying... which is annoying
    this.drawerElement.returnValue = "";
  };

  /**
   * Opens the drawer -- this will throw an error in development if the drawer element was not rendered
   */
  open = () => {
    assert(
      "Cannot call `open` on <Drawer> without rendering the drawer element.",
      this.drawerElement,
    );

    /**
     * If the element is already open, don't run all this again
     */
    if (this.drawerElement.hasAttribute("open")) {
      return;
    }

    /**
     * adds the `open` attribute
     */
    this.drawerElement.showModal();
    this.isOpen = true;
  };
}

export const Drawer = DrawerDialog;

export default DrawerDialog;


---

import type { TOC } from "@ember/component/template-only";

export const ExternalLink: TOC<{
  Element: HTMLAnchorElement;
  Blocks: {
    default: [];
  };
}> = <template>
  <a target="_blank" rel="noreferrer noopener" href="##missing##" ...attributes>
    {{yield}}
  </a>
</template>;

export default ExternalLink;


---

import { fn } from "@ember/helper";
import { on } from "@ember/modifier";

import { dataFrom } from "form-data-utils";

import type { TOC } from "@ember/component/template-only";

type Data = ReturnType<typeof dataFrom>;

export const dataFromEvent = dataFrom;

const handleInput = (
  onChange: (data: Data, eventType: "input" | "submit", event: Event) => void,
  event: Event | SubmitEvent,
  eventType: "input" | "submit" = "input",
) => {
  const data = dataFrom(event);

  onChange(data, eventType, event);
};

const handleSubmit = (
  onChange: (data: Data, eventType: "input" | "submit", event: Event | SubmitEvent) => void,
  event: SubmitEvent,
) => {
  event.preventDefault();
  handleInput(onChange, event, "submit");
};

export interface Signature {
  Element: HTMLFormElement;
  Args: {
    /**
     *  Any time the value of any field is changed this function will be called.
     */
    onChange: (
      /**
       * The data from the form as an Object of `{ [field name] => value }` pairs.
       * This is generated from the native [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
       *
       * Additional fields/inputs/controls can be added to this data by specifying a
       * "name" attribute.
       */
      data: Data,
      /**
       * Indicates whether the `onChange` function was called from the `input` or `submit` event handlers.
       */
      eventType: "input" | "submit",
      /**
       * The raw event, if needed.
       */
      event: Event | SubmitEvent,
    ) => void;
  };
  Blocks: {
    /**
     * The main content for the form. This is where inputs / fields / controls would go.
     * Within the `<form>` content, `<button type="submit">` will submit the form, which
     * triggers the `@onChange` event.
     */
    default: [];
  };
}

export const Form: TOC<Signature> = <template>
  <form
    {{on "input" (fn handleInput @onChange)}}
    {{on "submit" (fn handleSubmit @onChange)}}
    ...attributes
  >
    {{yield}}
  </form>
</template>;

export default Form;


---

import Component from "@glimmer/component";

import { element } from "ember-element-helper";
import { getSectionHeadingLevel } from "which-heading-do-i-need";

import type Owner from "@ember/owner";

export class Heading extends Component<{
  Element: HTMLElement;
  Blocks: { default: [] };
}> {
  headingScopeAnchor: Text;
  constructor(owner: Owner, args: object) {
    super(owner, args);

    this.headingScopeAnchor = document.createTextNode("");
  }

  get level() {
    return getSectionHeadingLevel(this.headingScopeAnchor);
  }

  get hLevel() {
    return `h${this.level}`;
  }

  <template>
    {{this.headingScopeAnchor}}

    {{#let (element this.hLevel) as |El|}}
      <El ...attributes>
        {{yield}}
      </El>
    {{/let}}
  </template>
}


---

import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { assert } from "@ember/debug";
import { isDestroyed, isDestroying, registerDestructor } from "@ember/destroyable";
import { buildWaiter } from "@ember/test-waiters";

import { cell } from "ember-resources";

import type Owner from "@ember/owner";

const DEFAULT_BATCH_SIZE = 50;
const DEFAULT_INITIAL = "sync";

const waiter = buildWaiter("ember-primitives:incremental-each");

function chunk<T>(arr: readonly T[], size: number): T[][] {
  const out: T[][] = [];

  for (let i = 0; i < arr.length; i += size) {
    out.push(arr.slice(i, i + size));
  }

  return out;
}

// Safari has `requestIdleCallback` behind a flag, effectively absent
// for end users. Fall back to `setTimeout(cb, 0)` — Safari users get
// the chunking benefit (one batch per task) without the idle-priority
// hint that other browsers honor.
const ric: typeof requestIdleCallback =
  typeof requestIdleCallback === "function"
    ? requestIdleCallback
    : (cb) => setTimeout(() => cb({ timeRemaining: () => 0, didTimeout: true }), 0);

export interface Signature<T = unknown> {
  Args: {
    /**
     * The collection of items to render.
     *
     * Replacing the array (new identity) restarts rendering from the
     * first batch.
     *
     * ```gjs
     * import { IncrementalEach } from 'ember-primitives';
     *
     * <template>
     *   <IncrementalEach @items={{this.rows}} as |row|>
     *     <my-row @row={{row}} />
     *   </IncrementalEach>
     * </template>
     * ```
     */
    items: readonly T[];

    /**
     * How many items to add per animation frame.
     *
     * Larger batches add more items per chunk; smaller batches yield to
     * the browser more often.
     *
     * Default: 50. Must be positive; `0` or less asserts in development.
     *
     * ```gjs
     * import { IncrementalEach } from 'ember-primitives';
     *
     * <template>
     *   <IncrementalEach @items={{this.rows}} @batchSize={{100}} as |row|>
     *     <my-row @row={{row}} />
     *   </IncrementalEach>
     * </template>
     * ```
     */
    batchSize?: number;

    /**
     * Controls how the initial batch is committed.
     *
     * - `"sync"` (default): the first `@batchSize` items render in the
     *   same render pass as mount / `@items` change. The user sees
     *   content on the very first paint, and the rest of the list
     *   fills in one batch per animation frame. This is the right
     *   default for most lists — even a perceived "empty for one
     *   frame" is worse than rendering a few extra items synchronously.
     * - `"lazy"`: even the first batch waits for an animation frame, so
     *   the initial paint is empty and content arrives one batch per
     *   frame. Use this when the first batch itself would be expensive
     *   enough to block the first paint, and you'd rather show an
     *   empty container than delay it.
     *
     * Default: `"sync"`.
     *
     * ```gjs
     * import { IncrementalEach } from 'ember-primitives';
     *
     * <template>
     *   <IncrementalEach @items={{this.rows}} @initial="lazy" as |row|>
     *     <my-row @row={{row}} />
     *   </IncrementalEach>
     * </template>
     * ```
     */
    initial?: "sync" | "lazy";

    /**
     * Called once with no arguments when every item in `@items` has
     * been committed to the DOM. Fires after the final batch lands;
     * does not fire on intermediate batches.
     *
     * Fires again on a fresh swap (new `@items` identity) once that
     * new collection finishes rendering. An empty `@items` array
     * does not fire the callback.
     *
     * Useful for marking the list as ready for screenshot tests,
     * dismissing a loading indicator, or measuring how long the
     * whole render took.
     *
     * ```gjs
     * import { IncrementalEach } from 'ember-primitives';
     *
     * <template>
     *   <IncrementalEach @items={{this.rows}} @onDone={{this.handleDone}} as |row|>
     *     <my-row @row={{row}} />
     *   </IncrementalEach>
     * </template>
     * ```
     */
    onDone?: () => void;
  };
  Blocks: {
    /**
     * Yielded for each rendered item, with the index in the original
     * `@items` array.
     *
     * ```gjs
     * import { IncrementalEach } from 'ember-primitives';
     *
     * <template>
     *   <IncrementalEach @items={{this.rows}} as |row index|>
     *     {{index}}: {{row.label}}
     *   </IncrementalEach>
     * </template>
     * ```
     */
    default: [item: T, index: number];
  };
}

/**
 * A drop-in replacement for `{{#each}}` that renders a large collection
 * a batch at a time on each animation frame, instead of all at once.
 *
 * Every item ends up in the DOM, so browser find (Ctrl+F / Cmd+F), anchor
 * links, screen readers, print, and SEO all work against the full list.
 * Yielding the main thread between batches keeps the page responsive while
 * the rest of the list is filling in.
 *
 * By default the first batch lands synchronously, so the user sees content
 * on the very first paint. Pass `@initial="lazy"` to defer the first batch
 * to an animation frame as well.
 *
 * Intended for non-scrollable containers, or anywhere a virtual/windowed
 * list does not apply (variable item heights, lists that grow the page,
 * surfaces that need every row indexable).
 *
 * Do not nest one `<IncrementalEach>` inside another. Each level adds an
 * animation-frame delay before its content paints; nesting compounds those
 * delays, so inner rows appear to flicker in with missing sub-content.
 * If you have nested loops, only the outermost one should be
 * `<IncrementalEach>`; leave deeper loops as plain `{{#each}}`.
 *
 * @example
 * ```gjs
 * import { IncrementalEach } from 'ember-primitives';
 *
 * <template>
 *   <ul>
 *     <IncrementalEach @items={{this.rows}} @batchSize={{100}} as |row index|>
 *       <li>{{index}}: {{row.label}}</li>
 *     </IncrementalEach>
 *   </ul>
 * </template>
 * ```
 */
export class IncrementalEach<T = unknown> extends Component<Signature<T>> {
  #count = cell(0);
  #itemsRef: readonly T[] | null = null;
  #waiterToken: unknown = null;
  #doneFor: object | null = null;

  constructor(owner: Owner, args: Signature<T>["Args"]) {
    super(owner, args);

    registerDestructor(this, () => this.#endWaiter());
  }

  // Reset progress and (re)open the test-waiter when `@items` identity
  // changes, so a swap restarts at the first batch, `@onDone` can fire
  // again for the new collection, and `await settled()` knows to wait
  // until `checkDone` closes the waiter. Mutating from a getter is safe
  // here because the writes happen before any consumer reads them in
  // the same render pass.
  /* eslint-disable ember/no-side-effects */
  get #items(): readonly T[] {
    const items = this.args.items;

    assert(`@items must be an array`, items);

    if (items !== this.#itemsRef) {
      this.#itemsRef = items;
      this.#count.current = 0;
      this.#endWaiter();

      if (items.length > 0) {
        this.#waiterToken = waiter.beginAsync();
      }
    }

    return items;
  }
  /* eslint-enable ember/no-side-effects */

  // `"sync"` keeps bucket 0 visible at count=0 (`i = 0 >= 0`); `"lazy"`
  // starts one step behind so even bucket 0 needs a tick.
  get #start() {
    return this.#initial === "sync" ? 0 : -1;
  }

  get i() {
    return this.#start + this.#count.current;
  }

  @cached
  get bucketed() {
    const size = this.#batchSize;

    return chunk(this.#items, size).map((items, b) => {
      const start = b * size;

      return {
        isReady: () => this.i >= b,
        items: items.map((value, j) => ({ value, index: start + j })),
      };
    });
  }

  get #batchSize(): number {
    const requested = this.args.batchSize ?? DEFAULT_BATCH_SIZE;

    assert(
      `<IncrementalEach> @batchSize must be a positive number, got ${requested}`,
      requested > 0,
    );

    return requested;
  }

  get #initial(): "sync" | "lazy" {
    const requested = this.args.initial ?? DEFAULT_INITIAL;

    assert(
      `<IncrementalEach> @initial must be "sync" or "lazy", got ${requested}`,
      requested === "sync" || requested === "lazy",
    );

    return requested;
  }

  // `#items` is read before `#count` so the count-reset inside `#items`
  // (on `@items` swap) lands before this read of count this render —
  // otherwise tracked-value backtracking asserts.
  tick = () => {
    if (this.#items.length > this.#count.current) {
      ric(() => this.#count.current++, { timeout: 10 });
    }
  };

  checkDone = () => {
    const bucketed = this.bucketed;

    if (this.#doneFor === bucketed) return;
    if (this.i < bucketed.length - 1) return;

    this.#doneFor = bucketed;
    queueMicrotask(() => {
      if (isDestroyed(this) || isDestroying(this)) return;
      this.args.onDone?.();
      this.#endWaiter();
    });
  };

  #endWaiter() {
    if (this.#waiterToken) waiter.endAsync(this.#waiterToken);
  }

  <template>
    {{(this.tick)}}{{#each this.bucketed as |bucket|}}{{#if (bucket.isReady)}}{{#each
          bucket.items
          as |entry|
        }}{{yield entry.value entry.index}}{{/each}}{{(this.checkDone)}}{{/if}}{{/each}}
  </template>
}


---

import type { TOC } from "@ember/component/template-only";

const isLast = (collection: unknown[], index: number) => index === collection.length - 1;
const isNotLast = (collection: unknown[], index: number) => !isLast(collection, index);
const isMac = navigator.userAgent.indexOf("Mac OS") >= 0;

function split(str: string) {
  const keys = str.split("+").map((x) => x.trim());

  return keys;
}

function getKeys(keys: string[] | string, mac?: string[] | string) {
  const normalKeys = Array.isArray(keys) ? keys : split(keys);

  if (!mac) {
    return normalKeys;
  }

  const normalMac = Array.isArray(mac) ? mac : split(mac);

  return isMac ? normalMac : normalKeys;
}

export interface KeyComboSignature {
  Element: HTMLElement;
  Args: {
    keys: string[] | string;
    mac?: string[] | string;
  };
}

export const KeyCombo: TOC<KeyComboSignature> = <template>
  <span class="ember-primitives__key-combination" ...attributes>
    {{#let (getKeys @keys @mac) as |keys|}}
      {{#each keys as |key i|}}
        <Key>{{key}}</Key>
        {{#if (isNotLast keys i)}}
          <span class="ember-primitives__key-combination__separator">+</span>
        {{/if}}
      {{/each}}
    {{/let}}
  </span>
</template>;

export interface KeySignature {
  Element: HTMLElement;
  Blocks: { default?: [] };
}

export const Key: TOC<KeySignature> = <template>
  <kbd class="ember-primitives__key" ...attributes>{{yield}}</kbd>
</template>;


---

/**
 * TODO: make template-only component,
 * and use class-based modifier?
 *
 * This would require that modifiers could run pre-render
 */
import { hash } from '@ember/helper';
import { on } from '@ember/modifier';

import { link } from '../helpers/link.ts';
import { ExternalLink } from './external-link.gts';

import type { TOC } from '@ember/component/template-only';

export interface Signature {
  Element: HTMLAnchorElement;
  Args: {
    /**
     * the `href` string value to set on the anchor element.
     */
    href: string;
    /**
     * When calculating the "active" state of the link, you may decide
     * whether or not you want to _require_ that all query params be considered (true)
     * or specify individual query params, ignoring anything not specified.
     *
     * For example:
     *
     * ```gjs live preview
     * import { Link } from 'ember-primitives';
     *
     * <template>
     *   <Link @href="/" @includeActiveQueryParams={{true}} as |a|>
     *     ...
     *   </Link>
     * </template>
     * ```
     *
     * the data-active state here will only be "true" on
     * - `/`
     * - `/?foo=2`
     * - `/?foo=&bar=`
     *
     */
    includeActiveQueryParams?: true | string[];
    /**
     * When calculating the "active" state of the link, you may decide
     * whether or not you want to consider sub paths to be active when
     * child routes/urls are active.
     *
     * For example:
     *
     * ```gjs live preview
     * import { Link } from 'ember-primitives';
     *
     * <template>
     *   <Link @href="/forum/1" @activeOnSubPaths={{true}} as |a|>
     *     ...
     *   </Link>
     * </template>
     * ```
     *
     * the data-active state here will be "true" on
     * - `/forum/1`
     * - `/forum/1/posts`
     * - `/forum/1/posts/comments`
     * - `/forum/1/*etc*`
     *
     * if `@activeOnSubPaths` is set to false or left off
     * the data-active state here will only be "true" on
     * - `/forum/1`
     *
     */
    activeOnSubPaths?: true;
  };
  Blocks: {
    default: [
      {
        /**
         * Indicates if the passed `href` is pointing to an external site.
         * Useful if you want your links to have additional context for when
         * a user is about to leave your site.
         *
         * For example:
         *
         * ```gjs live preview
         * import { Link } from 'ember-primitives';
         *
         * const MyLink = <template>
         *   <Link @href={{@href}} as |a|>
         *     {{yield}}
         *     {{#if a.isExternal}}
         *       ➚
         *     {{/if}}
         *   </Link>
         * </template>;
         *
         * <template>
         *   <MyLink @href="https://developer.mozilla.org">MDN</MyLink> &nbsp;&nbsp;
         *   <MyLink @href="/">Home</MyLink>
         *  </template>
         * ```
         */
        isExternal: boolean;
        /**
         * Indicates if the passed `href` is *active*, or the user is on the same basepath.
         * This allows consumers to style their link if they wish or style their text.
         * The active state will also be present on a `data-active` attribute on the generated anchor tag.
         *
         *
         * For example
         * ```gjs
         * import { Link, service } from 'ember-primitives';
         *
         * const MyLink = <template>
         *   <Link @href="..."> as |a|>
         *     <span class="{{if a.isActive 'underline'}}">
         *     {{yield}}
         *     </span>
         *   </Link>
         * </template>
         *
         * <template>
         * {{#let (service 'router') as |router|}}
         *     <MyLink @href={{router.currentURL}}>Ths page</MyLink> &nbsp;&nbsp;
         *     <MyLink @href="/">Home</MyLink>
         *   {{/let}}
         *  </template>
         * ```
         *
         * By default, the query params are omitted from `isActive` calculation, but you may
         * configure the query params to be included if you wish
         * See: `@includeActiveQueryParams`
         *
         * By default, only the exact route/url is considered for the `isActive` calculation,
         * but you may configure sub routes/paths to also be considered active
         * See: `@activeOnSubPaths`
         *
         * Note that external links are never active.
         */
        isActive: boolean;
      },
    ];
  };
}

/**
 * A light wrapper around the [Anchor element][mdn-a], which will appropriately make your link an external link if the passed `@href` is not on the same domain.
 *
 *
 * [mdn-a]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a
 */
export const Link: TOC<Signature> = <template>
  {{#let (link @href includeActiveQueryParams=@includeActiveQueryParams activeOnSubPaths=@activeOnSubPaths) as |l|}}
    {{#if l.isExternal}}
      <ExternalLink href={{@href}} ...attributes>
        {{yield (hash isExternal=true isActive=false)}}
      </ExternalLink>
    {{else}}
      <a
        data-active={{l.isActive}}
        href={{if @href @href "##missing##"}}
        {{on "click" l.handleClick}}
        ...attributes
      >
        {{yield (hash isExternal=false isActive=l.isActive)}}
      </a>
    {{/if}}
  {{/let}}
</template>;

export default Link;


---

import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { guidFor } from "@ember/object/internals";

import { modifier as eModifier } from "ember-modifier";
import { cell } from "ember-resources";
import { getTabster, getTabsterAttribute, MoverDirections, setTabsterAttribute } from "tabster";

import { Link, type Signature as LinkSignature } from "./link.gts";
import { Popover, type Signature as PopoverSignature } from "./popover.gts";

import type { TOC } from "@ember/component/template-only";
import type { WithBoundArgs } from "@glint/template";

type Cell<V> = ReturnType<typeof cell<V>>;
type LinkArgs = LinkSignature["Args"];
type PopoverArgs = PopoverSignature["Args"];
type PopoverBlockParams = PopoverSignature["Blocks"]["default"][0];

const TABSTER_CONFIG_CONTENT = getTabsterAttribute(
  {
    mover: {
      direction: MoverDirections.Both,
      cyclic: true,
    },
    deloser: {},
  },
  true,
);

const TABSTER_CONFIG_TRIGGER = {
  deloser: {},
};

export interface Signature {
  Args: PopoverArgs;
  Blocks: {
    default: [
      {
        arrow: PopoverBlockParams["arrow"];
        trigger: WithBoundArgs<
          typeof trigger,
          "triggerElement" | "contentId" | "isOpen" | "setReference"
        >;
        Trigger: WithBoundArgs<typeof Trigger, "triggerModifier">;
        Content: WithBoundArgs<
          typeof Content,
          "triggerElement" | "contentId" | "isOpen" | "PopoverContent"
        >;
        isOpen: boolean;
      },
    ];
  };
}

export interface SeparatorSignature {
  Element: HTMLDivElement;
  Blocks: { default: [] };
}

const Separator: TOC<SeparatorSignature> = <template>
  <div role="separator" ...attributes>
    {{yield}}
  </div>
</template>;

/**
 * We focus items on `pointerMove` to achieve the following:
 *
 * - Mouse over an item (it focuses)
 * - Leave mouse where it is and use keyboard to focus a different item
 * - Wiggle mouse without it leaving previously focused item
 * - Previously focused item should re-focus
 *
 * If we used `mouseOver`/`mouseEnter` it would not re-focus when the mouse
 * wiggles. This is to match native menu implementation.
 */
function focusOnHover(e: PointerEvent) {
  const item = e.currentTarget;

  if (item instanceof HTMLElement) {
    item?.focus();
  }
}

interface PrivateItemSignature {
  Element: HTMLButtonElement;
  Args: { onSelect?: (event: Event) => void; toggle: () => void };
  Blocks: { default: [] };
}

export interface ItemSignature {
  Element: PrivateItemSignature["Element"];
  Args: Omit<PrivateItemSignature["Args"], "toggle">;
  Blocks: PrivateItemSignature["Blocks"];
}

const Item: TOC<PrivateItemSignature> = <template>
  {{! @glint-expect-error }}
  {{#let (if @onSelect (modifier on "click" @onSelect)) as |maybeClick|}}
    <button
      type="button"
      role="menuitem"
      {{! @glint-expect-error }}
      {{maybeClick}}
      {{on "click" @toggle}}
      {{on "pointermove" focusOnHover}}
      ...attributes
    >
      {{yield}}
    </button>
  {{/let}}
</template>;

interface LinkItemArgs extends LinkArgs {
  toggle: () => void;
}

interface PrivateLinkItemSignature {
  Element: HTMLAnchorElement;
  Args: LinkItemArgs;
  Blocks: { default: [] };
}

export interface LinkItemSignature {
  Element: PrivateLinkItemSignature["Element"];
  Args: LinkArgs;
  Blocks: PrivateLinkItemSignature["Blocks"];
}

const LinkItem: TOC<PrivateLinkItemSignature> = <template>
  <Link
    role="menuitem"
    @href={{@href}}
    @includeActiveQueryParams={{@includeActiveQueryParams}}
    @activeOnSubPaths={{@activeOnSubPaths}}
    {{on "click" @toggle}}
    {{on "pointermove" focusOnHover}}
    ...attributes
  >
    {{yield}}
  </Link>
</template>;

const installContent = eModifier<{
  Element: HTMLElement;
  Args: {
    Named: {
      isOpen: Cell<boolean>;
      triggerElement: Cell<HTMLElement>;
    };
  };
}>((element, _: [], { isOpen, triggerElement }) => {
  // Focus first focusable element when the popover opens.
  // The toggle event fires natively after showPopover() completes.
  // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/toggle_event
  function onToggle(e: ToggleEvent) {
    if (e.newState !== "open") return;

    const tabster = getTabster(window);
    const firstFocusable = tabster?.focusable.findFirst({
      container: element,
    });

    firstFocusable?.focus();
  }

  element.addEventListener("toggle", onToggle as EventListener);

  // listen for "outside" clicks
  function onDocumentClick(e: MouseEvent) {
    if (
      isOpen.current &&
      e.target &&
      !element.contains(e.target as HTMLElement) &&
      !triggerElement.current?.contains(e.target as HTMLElement)
    ) {
      isOpen.current = false;
    }
  }

  // listen for the escape key
  function onDocumentKeydown(e: KeyboardEvent) {
    if (isOpen.current && e.key === "Escape") {
      isOpen.current = false;
    }
  }

  document.addEventListener("click", onDocumentClick);
  document.addEventListener("keydown", onDocumentKeydown);

  return () => {
    element.removeEventListener("toggle", onToggle as EventListener);
    document.removeEventListener("click", onDocumentClick);
    document.removeEventListener("keydown", onDocumentKeydown);
  };
});

interface PrivateContentSignature {
  Element: HTMLDivElement;
  Args: {
    triggerElement: Cell<HTMLElement>;
    contentId: string;
    isOpen: Cell<boolean>;
    PopoverContent: PopoverBlockParams["Content"];
  };
  Blocks: {
    default: [
      {
        Item: WithBoundArgs<typeof Item, "toggle">;
        LinkItem: WithBoundArgs<typeof LinkItem, "toggle">;
        Separator: typeof Separator;
      },
    ];
  };
}

export interface ContentSignature {
  Element: PrivateContentSignature["Element"];
  Blocks: PrivateContentSignature["Blocks"];
}

const Content: TOC<PrivateContentSignature> = <template>
  {{#if @isOpen.current}}
    <@PopoverContent
      id={{@contentId}}
      role="menu"
      data-tabster={{TABSTER_CONFIG_CONTENT}}
      tabindex="0"
      {{installContent isOpen=@isOpen triggerElement=@triggerElement}}
      ...attributes
    >
      {{yield
        (hash
          Item=(component Item toggle=@isOpen.toggle)
          LinkItem=(component LinkItem toggle=@isOpen.toggle)
          Separator=Separator
        )
      }}
    </@PopoverContent>
  {{/if}}
</template>;

interface PrivateTriggerModifierSignature {
  Element: HTMLElement;
  Args: {
    Named: {
      triggerElement: Cell<HTMLElement>;
      isOpen: Cell<boolean>;
      contentId: string;
      setReference: PopoverBlockParams["setReference"];
      stopPropagation?: boolean;
      preventDefault?: boolean;
    };
  };
}

export interface TriggerModifierSignature {
  Element: PrivateTriggerModifierSignature["Element"];
}

const trigger = eModifier<PrivateTriggerModifierSignature>(
  (
    element,
    _: [],
    { triggerElement, isOpen, contentId, setReference, stopPropagation, preventDefault },
  ) => {
    element.setAttribute("aria-haspopup", "menu");

    if (isOpen.current) {
      element.setAttribute("aria-controls", contentId);
      element.setAttribute("aria-expanded", "true");
    } else {
      element.removeAttribute("aria-controls");
      element.setAttribute("aria-expanded", "false");
    }

    setTabsterAttribute(element, TABSTER_CONFIG_TRIGGER);

    const onTriggerClick = (event: MouseEvent) => {
      if (stopPropagation) {
        event.stopPropagation();
      }

      if (preventDefault) {
        event.preventDefault();
      }

      isOpen.toggle();
    };

    element.addEventListener("click", onTriggerClick);

    triggerElement.current = element;

    setReference(element);

    return () => {
      element.removeEventListener("click", onTriggerClick);
    };
  },
);

interface PrivateTriggerSignature {
  Element: HTMLButtonElement;
  Args: {
    triggerModifier: WithBoundArgs<
      typeof trigger,
      "triggerElement" | "contentId" | "isOpen" | "setReference"
    >;
    stopPropagation?: boolean;
    preventDefault?: boolean;
  };
  Blocks: { default: [] };
}

export interface TriggerSignature {
  Element: PrivateTriggerSignature["Element"];
  Blocks: PrivateTriggerSignature["Blocks"];
}

const Trigger: TOC<PrivateTriggerSignature> = <template>
  <button
    type="button"
    {{@triggerModifier stopPropagation=@stopPropagation preventDefault=@preventDefault}}
    ...attributes
  >
    {{yield}}
  </button>
</template>;

const IsOpen = () => cell<boolean>(false);
const TriggerElement = () => cell<HTMLElement>();

export class Menu extends Component<Signature> {
  contentId = guidFor(this);

  <template>
    {{#let (IsOpen) (TriggerElement) as |isOpen triggerEl|}}
      <Popover
        @flipOptions={{@flipOptions}}
        @middleware={{@middleware}}
        @offsetOptions={{@offsetOptions}}
        @placement={{@placement}}
        @shiftOptions={{@shiftOptions}}
        @strategy={{@strategy}}
        as |p|
      >
        {{#let
          (modifier
            trigger
            triggerElement=triggerEl
            isOpen=isOpen
            contentId=this.contentId
            setReference=p.setReference
          )
          as |triggerModifier|
        }}
          {{yield
            (hash
              trigger=triggerModifier
              Trigger=(component Trigger triggerModifier=triggerModifier)
              Content=(component
                Content
                PopoverContent=p.Content
                isOpen=isOpen
                triggerElement=triggerEl
                contentId=this.contentId
              )
              arrow=p.arrow
              isOpen=isOpen.current
            )
          }}
        {{/let}}
      </Popover>
    {{/let}}
  </template>
}

export default Menu;


---

export { OTPInput } from "./one-time-password/input.gts";
export { OTP } from "./one-time-password/otp.gts";


---

import { hash } from "@ember/helper";

import { arrow } from "@floating-ui/dom";
import { element } from "ember-element-helper";
import { modifier as eModifier } from "ember-modifier";
import { cell } from "ember-resources";

import { FloatingUI } from "../floating-ui.ts";

import type { Signature as FloatingUiComponentSignature } from "../floating-ui/component.gts";
import type { Signature as HookSignature } from "../floating-ui/modifier.ts";
import type { TOC } from "@ember/component/template-only";
import type { ElementContext, Middleware } from "@floating-ui/dom";
import type { ModifierLike, WithBoundArgs } from "@glint/template";

export interface Signature {
  Args: {
    /**
     * See the Floating UI's [flip docs](https://floating-ui.com/docs/flip) for possible values.
     *
     * This argument is forwarded to the `<FloatingUI>` component.
     */
    flipOptions?: HookSignature["Args"]["Named"]["flipOptions"];
    /**
     * Array of one or more objects to add to Floating UI's list of [middleware](https://floating-ui.com/docs/middleware)
     *
     * This argument is forwarded to the `<FloatingUI>` component.
     */
    middleware?: HookSignature["Args"]["Named"]["middleware"];
    /**
     * See the Floating UI's [offset docs](https://floating-ui.com/docs/offset) for possible values.
     *
     * This argument is forwarded to the `<FloatingUI>` component.
     */
    offsetOptions?: HookSignature["Args"]["Named"]["offsetOptions"];
    /**
     * One of the possible [`placements`](https://floating-ui.com/docs/computeposition#placement). The default is 'bottom'.
     *
     * Possible values are
     * - top
     * - bottom
     * - right
     * - left
     *
     * And may optionally have `-start` or `-end` added to adjust position along the side.
     *
     * This argument is forwarded to the `<FloatingUI>` component.
     */
    placement?: `${"top" | "bottom" | "left" | "right"}${"" | "-start" | "-end"}`;
    /**
     * See the Floating UI's [shift docs](https://floating-ui.com/docs/shift) for possible values.
     *
     * This argument is forwarded to the `<FloatingUI>` component.
     */
    shiftOptions?: HookSignature["Args"]["Named"]["shiftOptions"];
    /**
     * CSS position property, either `fixed` or `absolute`.
     *
     * Pros and cons of each strategy are explained on [Floating UI's Docs](https://floating-ui.com/docs/computePosition#strategy)
     *
     * This argument is forwarded to the `<FloatingUI>` component.
     */
    strategy?: HookSignature["Args"]["Named"]["strategy"];
  };
  Blocks: {
    default: [
      {
        reference: FloatingUiComponentSignature["Blocks"]["default"][0];
        setReference: FloatingUiComponentSignature["Blocks"]["default"][2]["setReference"];
        Content: WithBoundArgs<typeof Content, "floating">;
        data: FloatingUiComponentSignature["Blocks"]["default"][2]["data"];
        arrow: ModifierLike<{ Element: HTMLElement }>;
      },
    ];
  };
}

const showPopover = eModifier<{ Element: Element }>((element) => {
  const el = element as HTMLElement;

  // Reset [popover] UA overflow default that clips arrows positioned outside
  el.style.setProperty("overflow", "visible");

  // Don't promote to top layer if already inside a popover — the parent
  // popover already handles layering. Adding both to the top layer causes
  // stacking issues where the parent renders on top of the child.
  if (el.parentElement?.closest("[popover]")) {
    el.removeAttribute("popover");

    // <dialog> elements are hidden by default — ensure they're visible
    // when opting out of the top layer.
    if (el instanceof HTMLDialogElement) {
      el.setAttribute("open", "");
    }
  } else {
    el.showPopover();
  }

  return () => {
    try {
      el.hidePopover();
    } catch {
      /* already hidden */
    }
  };
});

function getElementTag(tagName: undefined | string) {
  return tagName || "div";
}

/**
 * Content uses `popover="manual"` + `showPopover()` to promote
 * the element to the browser's top layer. This escapes all ancestor
 * overflow clipping and stacking contexts — the same guarantee that
 * portalling provided, but using the browser's native mechanism.
 */
const Content: TOC<{
  Element: HTMLDivElement;
  Args: {
    floating: ModifierLike<{ Element: HTMLElement }>;
    /**
     * By default the popover content is wrapped in a div.
     * You may change this by supplying the name of an element here.
     *
     * For example:
     * ```gjs
     * <Popover as |p|>
     *  <p.Content @as="dialog">
     *    this is now focus trapped
     *  </p.Content>
     * </Popover>
     * ```
     */
    as?: string;
  };
  Blocks: { default: [] };
}> = <template>
  {{#let (element (getElementTag @as)) as |El|}}
    {{! @glint-ignore
          https://github.com/tildeio/ember-element-helper/issues/91
          https://github.com/typed-ember/glint/issues/610
    }}
    <El popover="manual" {{showPopover}} {{@floating}} ...attributes>
      {{yield}}
    </El>
  {{/let}}
</template>;

interface AttachArrowSignature {
  Element: HTMLElement;
  Args: {
    Named: {
      arrowElement: ReturnType<typeof ArrowElement>;
      data:
        | undefined
        | {
            placement: string;
            middlewareData?: {
              arrow?: { x?: number; y?: number };
            };
          };
    };
  };
}

const arrowSides = {
  top: "bottom",
  right: "left",
  bottom: "top",
  left: "right",
};

type Direction = "top" | "bottom" | "left" | "right";
type Placement = `${Direction}${"" | "-start" | "-end"}`;

const attachArrow: ModifierLike<AttachArrowSignature> = eModifier<AttachArrowSignature>(
  (element, _: [], named) => {
    if (element === named.arrowElement.current) {
      if (!named.data) return;
      if (!named.data.middlewareData) return;

      const { arrow } = named.data.middlewareData;
      const { placement } = named.data;

      if (!arrow) return;
      if (!placement) return;

      const { x: arrowX, y: arrowY } = arrow;
      const otherSide = (placement as Placement).split("-")[0] as Direction;
      const staticSide = arrowSides[otherSide];

      Object.assign(named.arrowElement.current.style, {
        left: arrowX != null ? `${arrowX}px` : "",
        top: arrowY != null ? `${arrowY}px` : "",
        right: "",
        bottom: "",
        [staticSide]: "-4px",
      });

      return;
    }

    void (async () => {
      await Promise.resolve();
      named.arrowElement.set(element);
    })();
  },
);

const ArrowElement: () => ReturnType<typeof cell<HTMLElement>> = () => cell<HTMLElement>();

function maybeAddArrow(middleware: Middleware[] | undefined, element: Element | undefined) {
  const result = [...(middleware || [])];

  if (element) {
    result.push(arrow({ element }));
  }

  return result;
}

function flipOptions(options: HookSignature["Args"]["Named"]["flipOptions"]) {
  return {
    elementContext: "reference" as ElementContext,
    ...options,
  };
}

export const Popover: TOC<Signature> = <template>
  {{#let (ArrowElement) as |arrowElement|}}
    <FloatingUI
      @placement={{@placement}}
      @strategy={{@strategy}}
      @middleware={{maybeAddArrow @middleware arrowElement.current}}
      @flipOptions={{flipOptions @flipOptions}}
      @shiftOptions={{@shiftOptions}}
      @offsetOptions={{@offsetOptions}}
      as |reference floating extra|
    >
      {{#let (modifier attachArrow arrowElement=arrowElement data=extra.data) as |arrow|}}
        {{yield
          (hash
            reference=reference
            setReference=extra.setReference
            Content=(component Content floating=floating)
            data=extra.data
            arrow=arrow
          )
        }}
      {{/let}}
    </FloatingUI>
  {{/let}}
</template>;

export default Popover;


---

import { assert } from "@ember/debug";
import { isDevelopingApp, macroCondition } from "@embroider/macros";

import { modifier } from "ember-modifier";
import { TrackedMap, TrackedSet } from "tracked-built-ins";

import type { TOC } from "@ember/component/template-only";

const cache = new TrackedMap<string, Set<Element>>();

export const TARGETS = Object.freeze({
  popover: "ember-primitives__portal-targets__popover",
  tooltip: "ember-primitives__portal-targets__tooltip",
  modal: "ember-primitives__portal-targets__modal",
});

export function findNearestTarget(origin: Element, name: string): Element | undefined {
  assert(`first argument to \`findNearestTarget\` must be an element`, origin instanceof Element);
  assert(`second argument to \`findNearestTarget\` must be a string`, typeof name === `string`);

  let element: Element | undefined | null = null;

  let parent = origin.parentNode;

  const manuallyRegisteredSet = cache.get(name);
  const manuallyRegistered: Element[] | null = manuallyRegisteredSet?.size
    ? [...manuallyRegisteredSet]
    : null;

  /**
   * For use with <PortalTarget @name="hi" />
   */
  function findRegistered(host: ParentNode): Element | undefined {
    return manuallyRegistered?.find((element) => {
      if (host.contains(element)) {
        return element;
      }
    });
  }

  const selector = Object.values(TARGETS as Record<string, string>).includes(name)
    ? `[data-portal-name=${name}]`
    : name;

  /**
   * Default portals / non-registered -- here we match a query selector instead of an element
   */
  function findDefault(host: ParentNode): Element | undefined {
    return host.querySelector(selector) as Element;
  }

  const finder = manuallyRegistered ? findRegistered : findDefault;

  /**
   * Crawl up the ancestry looking for our portal target
   */
  while (!element && parent) {
    element = finder(parent);
    if (element) break;
    parent = parent.parentNode;
  }

  if (macroCondition(isDevelopingApp())) {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
    (window as any).prime0 = origin;
  }

  if (name.startsWith("ember-primitives")) {
    assert(
      `Could not find element by the given name: \`${name}\`.` +
        ` The known names are ` +
        `${Object.values(TARGETS).join(", ")} ` +
        `-- but any name will work as long as it is set to the \`data-portal-name\` attribute ` +
        `(or if the name has been specifically registered via the <PortalTarget /> component). ` +
        `Double check that the element you're wanting to portal to is rendered. ` +
        `The element passed to \`findNearestTarget\` is stored on \`window.prime0\` ` +
        `You can debug in your browser's console via ` +
        `\`document.querySelector('[data-portal-name="${name}"]')\``,
      element,
    );
  }

  return element ?? undefined;
}

const register = modifier((element: Element, [name]: [name: string]) => {
  assert(`@name is required when using <PortalTarget>`, name);

  void (async () => {
    // Bad TypeScript lint.
    // eslint-disable-next-line @typescript-eslint/await-thenable
    await 0;

    let existing = cache.get(name);

    if (!existing) {
      existing = new TrackedSet<Element>();
      cache.set(name, existing);
    }

    existing.add(element);
  })();

  return () => {
    cache.delete(name);
  };
});

export interface Signature {
  Element: null;
}

export const PortalTargets: TOC<Signature> = <template>
  <div data-portal-name={{TARGETS.popover}}></div>
  <div data-portal-name={{TARGETS.tooltip}}></div>
  <div data-portal-name={{TARGETS.modal}}></div>
</template>;

/**
 * For manually registering a PortalTarget for use with Portal
 */
export const PortalTarget: TOC<{
  Element: HTMLDivElement;
  Args: {
    /**
     * The name of the PortalTarget
     *
     * This exact string may be passed to `Portal`'s `@to` argument.
     */
    name: string;
  };
}> = <template>
  <div {{register @name}} ...attributes></div>
</template>;

export default PortalTargets;


---

import { assert } from "@ember/debug";
import { schedule } from "@ember/runloop";
import { buildWaiter } from "@ember/test-waiters";

import { modifier } from "ember-modifier";
import { cell, resource, resourceFactory } from "ember-resources";

import { isElement } from "../narrowing.ts";
import { findNearestTarget, type TARGETS } from "./portal-targets.gts";

import type { TOC } from "@ember/component/template-only";

type Targets = (typeof TARGETS)[keyof typeof TARGETS];

interface ToSignature {
  Args: {
    to: string;
    append?: boolean;
  };
  Blocks: {
    default: [];
  };
}
interface ElementSignature {
  Args: {
    to: Element;
    append?: boolean;
  };
  Blocks: {
    default: [];
  };
}

export interface Signature {
  Args: {
    /**
     * The name of the PortalTarget to render in to.
     * This is the value of the `data-portal-name` attribute
     * of the element you wish to render in to.
     *
     * This can also be an Element which pairs nicely with query-utilities such as the platform-native `querySelector`
     */
    to?: (Targets | (string & {})) | Element;

    /**
     * Set to true to append to the portal instead of replace
     *
     * Default: false
     */
    append?: boolean;
    /**
     * For ember-wormhole style behavior, this argument may be an id,
     * or a selector.
     * This can also be an element, in which case the behavior is identical to `@to`
     */
    wormhole?: string | Element;
  };
  Blocks: {
    /**
     * The portaled content
     */
    default: [];
  };
}

/**
 * Polyfill for ember-wormhole behavior
 *
 * Example usage:
 * ```gjs
 * import { wormhole, Portal } from 'ember-primitives/components/portal';
 *
 * <template>
 *   <div id="the-portal"></div>
 *
 *   <Portal @to={{wormhole "the-portal"}}>
 *     content renders in the above div
 *   </Portal>
 * </template>
 *
 * ```
 */
export function wormhole(query: string | null | undefined | Element) {
  assert(`Expected query/element to be truthy.`, query);

  if (isElement(query)) {
    return query;
  }

  let found = document.getElementById(query);

  found ??= document.querySelector(query);

  return found;
}

const anchor = modifier(
  (element: Element, [to, update]: [string, ReturnType<typeof ElementValue>["set"]]) => {
    const found = findNearestTarget(element, to);

    update(found);
  },
);

const ElementValue = () => cell<Element | ShadowRoot | null | undefined>();

const waiter = buildWaiter("ember-primitives:portal");

function wormholeCompat(selector: string | Element) {
  const target = wormhole(selector);

  if (target) return target;

  return resource(() => {
    const target = cell<Element | undefined | null>();

    const token = waiter.beginAsync();

    // eslint-disable-next-line ember/no-runloop
    schedule("afterRender", () => {
      const result = wormhole(selector);

      waiter.endAsync(token);
      target.current = result;
      assert(
        `Could not find element with id/selector \`${typeof selector === "string" ? selector : "<Element>"}\``,
        result,
      );
    });

    return () => target.current;
  });
}

resourceFactory(wormholeCompat);

export const Portal: TOC<Signature> = <template>
  {{#if (isElement @to)}}
    <ToElement @to={{@to}} @append={{@append}}>
      {{yield}}
    </ToElement>
  {{else if @wormhole}}
    {{#let (wormholeCompat @wormhole) as |target|}}
      {{#if target}}
        {{#in-element target insertBefore=null}}
          {{yield}}
        {{/in-element}}
      {{/if}}
    {{/let}}
  {{else if @to}}
    <Nestable @to={{@to}} @append={{@append}}>
      {{yield}}
    </Nestable>
  {{else}}
    {{assert "either @to or @wormhole is required. Received neither"}}
  {{/if}}
</template>;

const ToElement: TOC<ElementSignature> = <template>
  {{#if @append}}
    {{#in-element @to insertBefore=null}}
      {{yield}}
    {{/in-element}}
  {{else}}
    {{#in-element @to}}
      {{yield}}
    {{/in-element}}
  {{/if}}
</template>;

const Nestable: TOC<ToSignature> = <template>
  {{#let (ElementValue) as |target|}}
    {{! This div is always going to be empty,
          because it'll either find the portal and render content elsewhere,
          it it won't find the portal and won't render anything.
      }}
    {{! template-lint-disable no-inline-styles }}
    <div style="display:contents;" {{anchor @to target.set}}>
      {{#if target.current}}
        {{#if @append}}
          {{#in-element target.current insertBefore=null}}
            {{yield}}
          {{/in-element}}
        {{else}}
          {{#in-element target.current}}
            {{yield}}
          {{/in-element}}
        {{/if}}
      {{/if}}
    </div>
  {{/let}}
</template>;

export default Portal;


---

import Component from "@glimmer/component";
import { hash } from "@ember/helper";

import type { TOC } from "@ember/component/template-only";
import type { WithBoundArgs } from "@glint/template";

export interface Signature {
  Element: HTMLDivElement;
  Args: {
    /**
     * The current progress
     * This may be less than 0 or more than `max`,
     * but the resolved value (managed internally, and yielded out)
     * does not exceed the range [0, max]
     */
    value: number;
    /**
     * The max value, defaults to 100
     */
    max?: number;
  };
  Blocks: {
    default: [
      {
        /**
         * The indicator element with some state applied.
         * This can be used to style the progress of bar.
         */
        Indicator: WithBoundArgs<typeof Indicator, "value" | "max" | "percent">;
        /**
         * The value as a percent of how far along the indicator should be
         * positioned, between 0 and 100.
         * Will be rounded to two decimal places.
         */
        percent: number;
        /**
         * The value as a percent of how far along the indicator should be positioned,
         * between 0 and 1
         */
        decimal: number;
        /**
         * The resolved value within the limits of the progress bar.
         */
        value: number;
      },
    ];
  };
}

type ProgressState = "indeterminate" | "complete" | "loading";

const DEFAULT_MAX = 100;

/**
 * Non-negative, non-NaN, non-Infinite, positive, rational
 */
function isValidProgressNumber(value: number | undefined | null): value is number {
  if (typeof value !== "number") return false;
  if (!Number.isFinite(value)) return false;

  return value >= 0;
}

function progressState(value: number | undefined | null, maxValue: number): ProgressState {
  return value == null ? "indeterminate" : value === maxValue ? "complete" : "loading";
}

function getMax(userMax: number | undefined | null): number {
  return isValidProgressNumber(userMax) ? userMax : DEFAULT_MAX;
}

function getValue(userValue: number | undefined | null, maxValue: number): number {
  const max = getMax(maxValue);

  if (!isValidProgressNumber(userValue)) {
    return 0;
  }

  if (userValue > max) {
    return max;
  }

  return userValue;
}

function getValueLabel(value: number, max: number) {
  return `${Math.round((value / max) * 100)}%`;
}

const Indicator: TOC<{
  Element: HTMLDivElement;
  Args: { max: number; value: number; percent: number };
  Blocks: { default: [] };
}> = <template>
  <div
    ...attributes
    data-max={{@max}}
    data-value={{@value}}
    data-state={{progressState @value @max}}
    data-percent={{@percent}}
  >
    {{yield}}
  </div>
</template>;

export class Progress extends Component<Signature> {
  get max() {
    return getMax(this.args.max);
  }

  get value() {
    return getValue(this.args.value, this.max);
  }

  get valueLabel() {
    return getValueLabel(this.value, this.max);
  }

  get decimal() {
    return this.value / this.max;
  }

  get percent() {
    return Math.round(this.decimal * 100 * 100) / 100;
  }

  <template>
    <div
      ...attributes
      aria-valuemax={{this.max}}
      aria-valuemin="0"
      aria-valuenow={{this.value}}
      aria-valuetext={{this.valueLabel}}
      role="progressbar"
      data-value={{this.value}}
      data-state={{progressState this.value this.max}}
      data-max={{this.max}}
      data-min="0"
      data-percent={{this.percent}}
    >

      {{yield
        (hash
          Indicator=(component Indicator value=this.value max=this.max percent=this.percent)
          value=this.value
          percent=this.percent
          decimal=this.decimal
        )
      }}
    </div>
  </template>
}

export default Progress;


---

export { Rating } from "./rating/rating.gts";

import type { ComponentIcons } from "./rating/public-types.ts";

export type IconType = ComponentIcons["icon"];


---

.ember-primitives__resizable {
  display: flex;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.ember-primitives__resizable[data-orientation="horizontal"] {
  flex-direction: row;
}

.ember-primitives__resizable[data-orientation="vertical"] {
  flex-direction: column;
}

.ember-primitives__resizable__panel {
  /* until the group assigns sizes, share space equally */
  flex: 1 1 0px;
  overflow: hidden;
  min-width: 0;
  min-height: 0;
}

.ember-primitives__resizable__handle {
  flex: 0 0 auto;
  position: relative;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}

.ember-primitives__resizable__handle[data-orientation="horizontal"] {
  width: 0.5rem;
  cursor: col-resize;
}

.ember-primitives__resizable__handle[data-orientation="vertical"] {
  height: 0.5rem;
  cursor: row-resize;
}


---

import "./resizable.css";

import Component from "@glimmer/component";
import { on } from "@ember/modifier";

import { modifier } from "ember-modifier";

import { Consume, Provide } from "../dom-context.gts";
import { GroupState } from "./resizable/state.ts";

import type { Orientation } from "./resizable/state.ts";
import type { TOC } from "@ember/component/template-only";

export type { Orientation };

export interface PanelSignature {
  Element: HTMLDivElement;
  Args: {
    /**
     * The smallest size (in % of the group) this panel may be resized to.
     * Defaults to 0.
     */
    minSize?: number;
    /**
     * The largest size (in % of the group) this panel may be resized to.
     * Defaults to 100.
     */
    maxSize?: number;
    /**
     * The initial size (in % of the group).
     * Panels without a size share the remaining space equally.
     */
    size?: number;
    /**
     * When true, pressing Enter on the handle after this panel
     * collapses the panel to 0 (and restores it on the next press),
     * and dragging well past `@minSize` snaps it closed.
     *
     * While collapsed, the panel has a `data-collapsed` attribute.
     */
    collapsible?: boolean;
  };
  Blocks: {
    default: [];
  };
}

/**
 * A resizable region within a `<Resizable>` group.
 *
 * Panels declare their constraints as data attributes, so the group
 * discovers them with DOM queries -- there is no registration, and a
 * Panel may contain another `<Resizable>` to nest layouts.
 */
let panelId = 0;

function nextPanelId(): string {
  return `ember-primitives__resizable__panel--${panelId++}`;
}

export const Panel: TOC<PanelSignature> = <template>
  <div
    class="ember-primitives__resizable__panel"
    data-min-size={{@minSize}}
    data-max-size={{@maxSize}}
    data-size={{@size}}
    data-collapsible={{if @collapsible "true"}}
    ...attributes
    {{! after ...attributes: the id is component-owned (the handles'
        aria-controls depends on it being present and unique) }}
    id={{(nextPanelId)}}
  >
    {{yield}}
  </div>
</template>;

export interface HandleSignature {
  Element: HTMLDivElement;
  Blocks: {
    default: [];
  };
}

function onPointerDown(state: GroupState) {
  return (event: PointerEvent) => state.startDrag(event.currentTarget as HTMLElement, event);
}

function onKeyDown(state: GroupState) {
  return (event: KeyboardEvent) => state.handleKeyDown(event.currentTarget as HTMLElement, event);
}

/**
 * The draggable (and keyboard-operable) divider between two Panels.
 *
 * Follows the WAI-ARIA window-splitter pattern, and controls the Panel
 * immediately before it. Finds its group via DOM context, so it must be
 * rendered inside a `<Resizable>`.
 *
 * Give each handle an accessible name (e.g. `aria-label="Resize sidebar"`).
 */
export const Handle: TOC<HandleSignature> = <template>
  <Consume @key={{GroupState}} as |ctx|>
    <div
      class="ember-primitives__resizable__handle"
      role="separator"
      tabindex="0"
      data-orientation={{ctx.data.orientation}}
      {{on "pointerdown" (onPointerDown ctx.data)}}
      {{on "keydown" (onKeyDown ctx.data)}}
      ...attributes
    >
      {{yield}}
    </div>
  </Consume>
</template>;

export interface Signature {
  Element: HTMLDivElement;
  Args: {
    /**
     * Which direction the panels are laid out in.
     *
     * `horizontal` (the default) places panels side-by-side (resizing along the x-axis),
     * `vertical` stacks them (resizing along the y-axis).
     *
     * May be changed while rendered; panels keep their sizes.
     */
    orientation?: Orientation;
    /**
     * Called with the panels' sizes (percentages, in document order)
     * whenever the layout changes.
     *
     * Useful for persisting the layout.
     */
    onLayoutChange?: (sizes: number[]) => void;
  };
  Blocks: {
    default: [];
  };
}

/**
 * A group of resizable panels, separated by draggable handles.
 *
 * Render `<Panel>` and `<Handle>` components inside -- they are their
 * own imports, and find the group via DOM context / DOM queries.
 *
 * Groups can be nested (a `<Resizable>` inside a Panel) to build
 * tree-shaped tiling layouts, i3 / tmux style.
 */
export class Resizable extends Component<Signature> {
  state = new GroupState({
    orientation: () => this.args.orientation,
    onLayoutChange: () => this.args.onLayoutChange,
  });

  attach = modifier((element: HTMLElement) => this.state.attach(element));

  <template>
    <div
      class="ember-primitives__resizable"
      data-orientation={{this.state.orientation}}
      {{this.attach}}
      ...attributes
    >
      <Provide @data={{this.state}} @key={{GroupState}}>
        {{yield}}
      </Provide>
    </div>
  </template>
}


---

import Component from "@glimmer/component";
import { isDestroyed, isDestroying } from "@ember/destroyable";
import { hash } from "@ember/helper";

import { modifier } from "ember-modifier";

/**
 * Utility component for helping with scrolling in any direction within
 * any of the 4 directions: up, down, left, right.
 *
 * This can be used to auto-scroll content as new content is inserted into the scrollable area, or possibly to bring focus to something on the page.
 */
export class Scroller extends Component<{
  /**
   * A containing element is required - in this case, a div.
   * It must be scrollable for this component to work, but can be customized.
   *
   * By default, this element will have some styling applied:
   *   overflow: auto;
   *
   * By default, this element will have tabindex="0" to support keyboard usage.
   *
   * The scroll-behavior is "auto", which can be controlled via CSS
   * https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior
   *
   */
  Element: HTMLDivElement;
  Blocks: {
    default: [
      {
        /**
         * Scroll the content to the bottom
         *
         * ```gjs
         * import { Scroller } from 'ember-primitives';
         *
         * <template>
         *   <Scroller as |s|>
         *      ...
         *
         *      {{ (s.scrollToBottom) }}
         *   </Scroller>
         * </template>
         * ```
         */
        scrollToBottom: () => void;
        /**
         * Scroll the content to the top
         *
         * ```gjs
         * import { Scroller } from 'ember-primitives';
         *
         * <template>
         *   <Scroller as |s|>
         *      ...
         *
         *      {{ (s.scrollToTop) }}
         *   </Scroller>
         * </template>
         * ```
         */
        scrollToTop: () => void;
        /**
         * Scroll the content to the left
         *
         * ```gjs
         * import { Scroller } from 'ember-primitives';
         *
         * <template>
         *   <Scroller as |s|>
         *      ...
         *
         *      {{ (s.scrollToLeft) }}
         *   </Scroller>
         * </template>
         * ```
         */
        scrollToLeft: () => void;
        /**
         * Scroll the content to the right
         *
         * ```gjs
         * import { Scroller } from 'ember-primitives';
         *
         * <template>
         *   <Scroller as |s|>
         *      ...
         *
         *      {{ (s.scrollToRight) }}
         *   </Scroller>
         * </template>
         * ```
         */
        scrollToRight: () => void;
      },
    ];
  };
}> {
  declare withinElement: HTMLDivElement;

  ref = modifier((el: HTMLDivElement) => {
    this.withinElement = el;
  });

  #frame?: number;

  scrollToBottom = () => {
    if (this.#frame) {
      cancelAnimationFrame(this.#frame);
    }

    this.#frame = requestAnimationFrame(() => {
      if (isDestroyed(this) || isDestroying(this)) return;

      this.withinElement.scrollTo({
        top: this.withinElement.scrollHeight,
        behavior: "auto",
      });
    });
  };

  scrollToTop = () => {
    if (this.#frame) {
      cancelAnimationFrame(this.#frame);
    }

    this.#frame = requestAnimationFrame(() => {
      if (isDestroyed(this) || isDestroying(this)) return;

      this.withinElement.scrollTo({
        top: 0,
        behavior: "auto",
      });
    });
  };

  scrollToLeft = () => {
    if (this.#frame) {
      cancelAnimationFrame(this.#frame);
    }

    this.#frame = requestAnimationFrame(() => {
      if (isDestroyed(this) || isDestroying(this)) return;

      this.withinElement.scrollTo({
        left: 0,
        behavior: "auto",
      });
    });
  };

  scrollToRight = () => {
    if (this.#frame) {
      cancelAnimationFrame(this.#frame);
    }

    this.#frame = requestAnimationFrame(() => {
      if (isDestroyed(this) || isDestroying(this)) return;

      this.withinElement.scrollTo({
        left: this.withinElement.scrollWidth,
        behavior: "auto",
      });
    });
  };

  <template>
    <div tabindex="0" ...attributes {{this.ref}}>
      {{yield
        (hash
          scrollToBottom=this.scrollToBottom
          scrollToTop=this.scrollToTop
          scrollToLeft=this.scrollToLeft
          scrollToRight=this.scrollToRight
        )
      }}
    </div>
  </template>
}


---

import { element } from "ember-element-helper";

import type { TOC } from "@ember/component/template-only";

type Orientation = "horizontal" | "vertical";

function normalizeTagName(tagName: string) {
  return tagName.trim().toLowerCase();
}

function getElementTag(tagName: undefined | string) {
  if (tagName) return tagName;

  return "hr";
}

function roleFor(tagName: string, decorative: undefined | boolean) {
  if (decorative) return undefined;

  // <hr> already has implicit role="separator".
  if (normalizeTagName(tagName) === "hr") return undefined;

  return "separator";
}

function ariaHiddenFor(decorative: undefined | boolean) {
  return decorative ? "true" : undefined;
}

function ariaOrientationFor(orientation: undefined | Orientation, decorative: undefined | boolean) {
  if (decorative) return undefined;

  // `separator` has an implicit aria-orientation of horizontal.
  // Only specify when authors opt in (e.g. vertical separators).
  return orientation;
}

function shouldYield(decorative: undefined | boolean, tagName: string) {
  // `<hr>` is a void element and must not have children.
  if (normalizeTagName(tagName) === "hr") return false;

  // Content inside a `separator` is presentational to AT; only yield for decorative
  // separators so consumers don't accidentally rely on it for semantics.
  return Boolean(decorative);
}

export interface Signature {
  Element: HTMLElement;
  Args: {
    /**
     * The tag name to use for the separator element.
     * Defaults to `<hr>` for non-decorative separators.
     * You can override this (e.g. `"li"` in menus, or `"span"` for inline separators).
     *
     * For example, in breadcrumbs where separators are siblings to `<li>` elements:
     * ```gjs
     * <Separator @as="li" @decorative={{true}}>/</Separator>
     * ```
     */
    as?: string;

    /**
     * When true, hides the separator from assistive technologies.
     *
     * Use this for purely decorative separators, such as breadcrumb slashes.
     */
    decorative?: boolean;

    /**
     * Sets `aria-orientation`. `separator` has an implicit orientation of `horizontal`.
     * Provide this when the separator is vertical.
     */
    orientation?: Orientation;
  };
  Blocks: {
    default: [];
  };
}

/**
 * A separator component that follows the ARIA `separator` role guidance.
 *
 * By default, this component renders a semantic separator (`<hr>`). When using a
 * non-`hr` tag via `@as`, it adds `role="separator"`.
 *
 * For purely decorative separators (e.g. breadcrumb slashes), set `@decorative={{true}}`
 * to apply `aria-hidden="true"`.
 *
 * For example:
 *
 * ```gjs live preview
 * import { Separator } from 'ember-primitives';
 *
 * <template>
 *   <nav>
 *     <ol style="display: flex; gap: 0.5rem; list-style: none; padding: 0;">
 *       <li><a href="/">Home</a></li>
 *       <Separator @as="li" @decorative={{true}}>/</Separator>
 *       <li><a href="/docs">Docs</a></li>
 *       <Separator @as="li" @decorative={{true}}>/</Separator>
 *       <li>Current</li>
 *     </ol>
 *   </nav>
 * </template>
 * ```
 */
export const Separator: TOC<Signature> = <template>
  {{#let (getElementTag @as) as |tagName|}}
    {{#let (element tagName) as |El|}}
      <El
        aria-hidden={{ariaHiddenFor @decorative}}
        role={{roleFor tagName @decorative}}
        aria-orientation={{ariaOrientationFor @orientation @decorative}}
        ...attributes
      >
        {{#if (shouldYield @decorative tagName)}}
          {{yield}}
        {{/if}}
      </El>
    {{/let}}
  {{/let}}
</template>;

export default Separator;


---

import Component from "@glimmer/component";

import type Owner from "@ember/owner";

// index.html has the production-fingerprinted references to these links
// Ideally, we'd have some pre-processor scan everything for references to
// assets in public, but idk how to set that up
const getStyles = () => [...document.querySelectorAll("link")].map((link) => link.href);

/**
 * style + native @import
 * is the only robust way to load styles in a shadowroot.
 *
 * link is only valid in the head element.
 */
const Styles = <template>
  <style>
    {{#each (getStyles) as |styleHref|}}

      @import "{{styleHref}}";

    {{/each}}
  </style>
</template>;

/**
 * Render content in a shadow dom, attached to a div.
 *
 * Uses the [shadow DOM][mdn-shadow-dom] API.
 *
 * [mdn-shadow-dom]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM
 *
 * This is useful when you want to render content that escapes your app's styles.
 */
export class Shadowed extends Component<{
  /**
   * The shadow dom attaches to a div element.
   * You may specify any attribute, and it'll be applied to this host element.
   */
  Element: HTMLDivElement;
  Args: {
    /**
     * @public
     *
     * By default, shadow-dom does not include any styles.
     * Setting this to true will include all the `<style>` tags
     * that are present in the `<head>` element.
     */
    includeStyles?: boolean;
  };
  Blocks: {
    /**
     * Content to be placed within the ShadowDOM
     */
    default: [];
  };
}> {
  shadow: HTMLDivElement;
  host: HTMLDivElement;
  /**
   * ember-source 5.6 broke the ability to in-element
   * natively into a shadowroot.
   *
   * We have two or three more dives than we should have here.
   *
   *
   * See these ember-source bugs:
   * - https://github.com/emberjs/ember.js/issues/20643
   * - https://github.com/emberjs/ember.js/issues/20642
   * - https://github.com/emberjs/ember.js/issues/20641
   *
   * Ideally, shadowdom should be built in.
   * Couple paths forward:
   *  - (as the overall template tag)
   *     <template shadowrootmode="open">
   *     </template>
   *
   *  - Build a component into the framework that does the above ^
   *  - add additional parsing in content-tag to allow
   *    nested <template>
   *
   */
  constructor(owner: Owner, args: { includeStyles?: boolean }) {
    super(owner, args);

    const element = document.createElement("div");
    const shadowRoot = element.attachShadow({ mode: "open" });
    const div = document.createElement("div");

    shadowRoot.appendChild(div);
    this.host = element;
    this.shadow = div;
  }

  <template>
    <div ...attributes>{{this.host}}</div>

    {{#in-element this.shadow}}

      {{#if @includeStyles}}
        <Styles />
      {{/if}}

      {{yield}}

    {{/in-element}}
  </template>
}

export default Shadowed;


---

/**
 * Structural styles for <Slider>.
 *
 * These handle the annoying parts of building a custom slider on top of
 * native <input type="range"> elements:
 *   - stretching an invisible native input over the track (so keyboard,
 *     pointer, and assistive-tech behavior all come from the platform)
 *   - letting multiple overlapping inputs coexist (multi-thumb / range
 *     sliders) by routing pointer events through the native thumb only
 *   - vertical orientation (via `writing-mode`, no rotation hacks)
 *   - positioning the visual thumb and keeping the active thumb on top
 *
 * Appearance (colors, exact sizes) is left to the consumer.
 *
 * Everything is wrapped in `@layer ember-primitives`, so *any* unlayered
 * consumer rule overrides these -- regardless of specificity or order.
 *
 * This is a plain stylesheet (bundled like any other CSS), so styles are
 * present at first layout -- no waiting on a render cycle. If you render
 * the slider inside a shadow root, bring this stylesheet into that root
 * yourself (e.g. adopt it, or @import it in the shadow tree).
 *
 * Knobs:
 *   --ember-primitives__slider__hit-area         pointer target size (default 24px)
 *   --ember-primitives__slider__thumb-size       visual thumb size (default 16px)
 *   --ember-primitives__slider__track-thickness  rail thickness (default 4px)
 *   --ember-primitives__slider__vertical-size    length of a vertical slider (default 10rem)
 */
@layer ember-primitives {
  .ember-primitives__slider {
    position: relative;
    display: flex;
    align-items: center;
    min-height: var(--ember-primitives__slider__hit-area, 24px);
  }

  .ember-primitives__slider[data-orientation="vertical"] {
    flex-direction: column;
    min-height: 0;
    min-width: var(--ember-primitives__slider__hit-area, 24px);
    height: var(--ember-primitives__slider__vertical-size, 10rem);
  }

  .ember-primitives__slider__track {
    position: relative;
    flex: 1;
    height: var(--ember-primitives__slider__track-thickness, 4px);
  }

  .ember-primitives__slider[data-orientation="vertical"] .ember-primitives__slider__track {
    height: auto;
    width: var(--ember-primitives__slider__track-thickness, 4px);
  }

  .ember-primitives__slider__range {
    position: absolute;
    top: 0;
    bottom: 0;
  }

  .ember-primitives__slider[data-orientation="vertical"] .ember-primitives__slider__range {
    top: auto;
    bottom: auto;
    left: 0;
    right: 0;
  }

  /*
    The native input is stretched across the whole track, invisible, and only
    used for interaction. The visual thumb (a sibling) is what users see.
  */
  .ember-primitives__slider__thumb-input {
    position: absolute;
    left: 0;
    top: 50%;
    translate: 0 -50%;
    width: 100%;
    height: var(--ember-primitives__slider__hit-area, 24px);
    margin: 0;
    opacity: 0;
    appearance: none;
    background: transparent;
    cursor: pointer;
  }

  .ember-primitives__slider__thumb-input:disabled {
    cursor: not-allowed;
  }

  .ember-primitives__slider__thumb-input[data-active] {
    z-index: 2;
  }

  /*
    Size the (invisible) native thumb to the hit area, so grabbing "the thumb"
    feels right. These cannot be comma-combined: an unknown pseudo-element
    invalidates the whole selector list in the other engine.
  */
  .ember-primitives__slider__thumb-input::-webkit-slider-thumb {
    appearance: none;
    width: var(--ember-primitives__slider__hit-area, 24px);
    height: var(--ember-primitives__slider__hit-area, 24px);
  }

  .ember-primitives__slider__thumb-input::-moz-range-thumb {
    border: none;
    width: var(--ember-primitives__slider__hit-area, 24px);
    height: var(--ember-primitives__slider__hit-area, 24px);
  }

  /*
    Multi-thumb sliders overlap multiple full-width range inputs. If the
    inputs themselves receive pointer events, the top-most input steals
    clicks/drags from the other thumbs. Disable pointer events on the
    track-sized input and re-enable them on the native thumb only.

    Single-thumb sliders keep the whole input interactive, so clicking
    anywhere on the track jumps to that value.
  */
  .ember-primitives__slider[data-multi] .ember-primitives__slider__thumb-input {
    pointer-events: none;
  }

  .ember-primitives__slider[data-multi]
    .ember-primitives__slider__thumb-input::-webkit-slider-thumb {
    pointer-events: auto;
  }

  .ember-primitives__slider[data-multi] .ember-primitives__slider__thumb-input::-moz-range-thumb {
    pointer-events: auto;
  }

  /*
    Vertical orientation: modern engines render a native vertical range input
    with `writing-mode`. `direction: rtl` puts the minimum at the bottom.
  */
  .ember-primitives__slider[data-orientation="vertical"] .ember-primitives__slider__thumb-input {
    writing-mode: vertical-lr;
    direction: rtl;
    top: 0;
    left: 50%;
    translate: -50% 0;
    width: var(--ember-primitives__slider__hit-area, 24px);
    height: 100%;
  }

  /*
    The visual thumb. The component positions it with an inline
    `left`/`bottom` percentage; centering uses the `translate` property
    (not `transform`) so consumer hover/active effects like
    `transform: scale(1.4)` or `scale: 1.4` compose with it instead of
    clobbering it.
  */
  .ember-primitives__slider__thumb {
    position: absolute;
    top: 50%;
    translate: -50% -50%;
    pointer-events: none;
    z-index: 1;
  }

  .ember-primitives__slider__thumb[data-active] {
    z-index: 3;
  }

  .ember-primitives__slider[data-orientation="vertical"] .ember-primitives__slider__thumb {
    top: auto;
    left: 50%;
    translate: -50% 50%;
  }

  /*
    Default appearance -- zero specificity via :where(), so any consumer rule
    wins (even a layered one). Colors derive from currentColor so the slider
    adapts to its context.
  */
  :where(.ember-primitives__slider__track) {
    border-radius: calc(var(--ember-primitives__slider__track-thickness, 4px) / 2);
    background: color-mix(in srgb, currentColor 20%, transparent);
  }

  :where(.ember-primitives__slider__range) {
    border-radius: inherit;
    background: currentColor;
  }

  :where(.ember-primitives__slider__thumb) {
    width: var(--ember-primitives__slider__thumb-size, 16px);
    height: var(--ember-primitives__slider__thumb-size, 16px);
    border-radius: 50%;
    background: currentColor;
  }

  :where(.ember-primitives__slider__thumb[data-disabled]) {
    opacity: 0.5;
  }

  /* Keyboard focus is on the (invisible) input; reflect it on the visual thumb. */
  :where(.ember-primitives__slider__thumb-input:focus-visible + .ember-primitives__slider__thumb) {
    outline: 2px solid currentColor;
    outline-offset: 2px;
  }
}


---

import "./slider.css";

import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";

import { SliderStore, type SliderThumb, type StyleString } from "./slider/store.ts";

import type { TOC } from "@ember/component/template-only";
import type Owner from "@ember/owner";
import type { WithBoundArgs } from "@glint/template";

export type { SliderThumb };

export interface Signature {
  Element: HTMLSpanElement;
  Args: {
    /**
     * The current value(s) of the slider.
     * For single value slider, pass a single number.
     * For range slider, pass an array of numbers [min, max].
     */
    value?: number | number[];
    /**
     * The minimum value of the slider.
     * Defaults to 0.
     */
    min?: number;
    /**
     * The maximum value of the slider.
     * Defaults to 100.
     */
    max?: number;
    /**
     * The stepping interval.
     *
     * When passed a number, the slider moves in fixed increments.
     * When passed an array of numbers, the slider snaps to those discrete values.
     * Defaults to 1.
     */
    step?: number | number[];
    /**
     * The orientation of the slider.
     * Defaults to 'horizontal'.
     */
    orientation?: "horizontal" | "vertical";
    /**
     * Whether the slider is disabled.
     */
    disabled?: boolean;
    /**
     * Callback when the value changes during dragging.
     */
    onValueChange?: (value: number | number[]) => void;
    /**
     * Callback when the value is committed (after dragging ends).
     */
    onValueCommit?: (value: number | number[]) => void;
  };
  Blocks: {
    default: [
      {
        /**
         * The track element - the rail along which the thumb moves
         */
        Track: typeof Track;
        /**
         * The range element - the filled portion of the track
         */
        Range: WithBoundArgs<typeof Range, "rangeStyle">;
        /**
         * The thumb element - the draggable handle(s)
         */
        Thumb: WithBoundArgs<typeof ThumbComponent, "store">;
        /**
         * The current value(s)
         */
        values: number[];

        /**
         * The tick values, if any.
         */
        tickValues: number[] | null;

        /**
         * A stable list of thumbs to iterate over.
         * Prefer this over iterating `values` directly to avoid DOM churn during dragging.
         */
        thumbs: SliderThumb[];
        /**
         * The minimum value
         */
        min: number;
        /**
         * The maximum value
         */
        max: number;
        /**
         * The step value
         */
        step: number;
      },
    ];
  };
}

interface TrackSignature {
  Element: HTMLSpanElement;
  Args: Record<string, never>;
  Blocks: {
    default: [];
  };
}

const Track: TOC<TrackSignature> = <template>
  <span ...attributes class="ember-primitives__slider__track">
    {{yield}}
  </span>
</template>;

interface RangeSignature {
  Element: HTMLSpanElement;
  Args: {
    rangeStyle: StyleString;
  };
}

const Range: TOC<RangeSignature> = <template>
  <span ...attributes class="ember-primitives__slider__range" style={{@rangeStyle}} />
</template>;

class ThumbComponent extends Component<{
  /**
   * `...attributes` land on the invisible native input, which is the
   * interactive element (pass `aria-label` / `aria-labelledby` here).
   */
  Element: HTMLInputElement;
  Args: {
    store: SliderStore;
    /**
     * Optional convenience: pass the full thumb object instead of `@value` + `@index`.
     */
    thumb?: SliderThumb;
    value?: number;
    index?: number;
  };
  Blocks: {
    /**
     * Rendered inside the visual thumb — useful for tooltips / value labels.
     */
    default: [];
  };
}> {
  get index(): number {
    return this.args.thumb?.index ?? this.args.index ?? 0;
  }

  get value(): number {
    // When using tick values, the `input` needs the internal index.
    return this.args.thumb?.inputValue ?? this.args.value ?? this.args.store.internalMin;
  }

  get isActive(): boolean {
    return this.args.store.activeThumbIndex === this.index;
  }

  get positionStyle() {
    const percent = this.args.thumb?.percent ?? this.args.store.thumbPercents[this.index] ?? 0;

    return this.args.store.thumbPositionStyle(percent);
  }

  private readValue(event: Event): number {
    // In docs live previews the component may run in an iframe/shadow realm,
    // where `instanceof HTMLInputElement` is not reliable. `currentTarget` is
    // the element the handler is attached to.
    const el = event.currentTarget as { value?: string } | null;
    const raw = el?.value;
    const parsed = raw === undefined ? NaN : Number.parseFloat(raw);

    return Number.isFinite(parsed) ? parsed : this.value;
  }

  private onInput = (event: Event) => {
    this.args.store.handleThumbActivate(this.index);
    this.args.store.handleThumbInput(this.index, this.readValue(event));
  };

  private onChange = (event: Event) => {
    this.args.store.handleThumbActivate(this.index);
    this.args.store.handleThumbChange(this.index, this.readValue(event));
  };

  private onPointerUp = () => {
    this.args.store.handleThumbActivate(this.index);
  };

  private onGotPointerCapture = () => {
    this.args.store.handleThumbActivate(this.index);
  };

  private onFocus = () => {
    this.args.store.handleThumbActivate(this.index);
  };

  <template>
    <input
      ...attributes
      class="ember-primitives__slider__thumb-input"
      type="range"
      min={{@store.internalMin}}
      max={{@store.internalMax}}
      step={{@store.internalStep}}
      value={{this.value}}
      disabled={{@store.disabled}}
      data-active={{if this.isActive ""}}
      {{on "gotpointercapture" this.onGotPointerCapture}}
      {{on "pointerup" this.onPointerUp}}
      {{on "focus" this.onFocus}}
      {{on "input" this.onInput}}
      {{on "change" this.onChange}}
    />
    <span
      class="ember-primitives__slider__thumb"
      style={{this.positionStyle}}
      data-active={{if this.isActive ""}}
      data-disabled={{if @store.disabled ""}}
      aria-hidden="true"
    >{{yield}}</span>
  </template>
}

export class Slider extends Component<Signature> {
  store: SliderStore;

  constructor(owner: Owner, args: Signature["Args"]) {
    super(owner, args);

    this.store = new SliderStore(() => this.args);
  }

  <template>
    <span
      ...attributes
      class="ember-primitives__slider"
      data-orientation={{this.store.orientation}}
      data-disabled={{if this.store.disabled ""}}
      data-multi={{if this.store.isMulti ""}}
    >
      {{#if (has-block)}}
        {{yield
          (hash
            Track=Track
            Range=(component Range rangeStyle=this.store.rangeStyle)
            Thumb=(component ThumbComponent store=this.store)
            values=this.store.values
            tickValues=this.store.tickValues
            thumbs=this.store.thumbs
            min=this.store.min
            max=this.store.max
            step=this.store.step
          )
        }}
      {{else}}
        <Track>
          <Range @rangeStyle={{this.store.rangeStyle}} />

          {{#each this.store.thumbs as |thumb|}}
            <ThumbComponent
              @store={{this.store}}
              @thumb={{thumb}}
              aria-label={{this.store.defaultThumbLabel thumb.index}}
            />
          {{/each}}
        </Track>
      {{/if}}
    </span>
  </template>
}

export default Slider;


---

import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";

import { cell } from "ember-resources";

import { uniqueId } from "../utils.ts";
import { Label } from "./-private/typed-elements.gts";

import type { TOC } from "@ember/component/template-only";
import type { WithBoundArgs } from "@glint/template";

export interface Signature {
  Element: HTMLInputElement;
  Args: {
    /**
     * The initial checked value of the Switch.
     * This value is reactive, so if the value that
     * `@checked` is set to updates, the state of the Switch will also update.
     */
    checked?: boolean;
    /**
     * Callback when the Switch state is toggled
     */
    onChange?: (checked: boolean, event: Event) => void;
  };
  Blocks: {
    default?: [
      {
        /**
         * The current state of the Switch.
         *
         * ```gjs
         * import { Switch } from 'ember-primitives/components/switch';
         *
         * <template>
         *   <Switch as |s|>
         *     {{s.isChecked}}
         *   </Switch>
         * </template>
         * ```
         */
        isChecked: boolean;
        /**
         * The Switch Element.
         * It has a pre-wired `id` so that the relevant Label is
         * appropriately associated via the `for` property of the Label.
         *
         * ```gjs
         * import { Switch } from 'ember-primitives/components/switch';
         *
         * <template>
         *   <Switch as |s|>
         *     <s.Control />
         *   </Switch>
         * </template>
         * ```
         */
        Control: WithBoundArgs<typeof Checkbox, "checked" | "id" | "onChange">;
        /**
         * The Switch element requires a label, and this label already has
         * the association to the Control by setting the `for` attribute to the `id` of the Control
         *
         * ```gjs
         * import { Switch } from 'ember-primitive/components/switchs';
         *
         * <template>
         *   <Switch as |s|>
         *     <s.Label />
         *   </Switch>
         * </template>
         * ```
         */
        Label: WithBoundArgs<typeof Label, "for">;
      },
    ];
  };
}

interface ControlSignature {
  Element: HTMLInputElement;
  Args: {
    id: string;
    checked?: ReturnType<typeof cell<boolean>>;
    onChange?: (checked: boolean, event: Event) => void;
  };
}

class Checkbox extends Component<ControlSignature> {
  handleClick = (event: Event) => {
    const newChecked = (event.target as HTMLInputElement).checked;

    if (this.args.onChange) {
      this.args.onChange(newChecked, event);
    } else {
      this.args.checked?.toggle();
    }
  };

  <template>
    <input
      id={{@id}}
      type="checkbox"
      role="switch"
      checked={{@checked.current}}
      aria-checked={{@checked.current}}
      data-state={{if @checked.current "on" "off"}}
      {{on "click" this.handleClick}}
      ...attributes
    />
  </template>
}

function defaultFalse(value: unknown) {
  return value ?? false;
}

/**
 * @public
 */
export const Switch: TOC<Signature> = <template>
  <div ...attributes data-prim-switch>
    {{#let (uniqueId) as |id|}}
      {{#let (cell (defaultFalse @checked)) as |checked|}}
        {{! @glint-nocheck }}
        {{yield
          (hash
            isChecked=checked.current
            Control=(component Checkbox checked=checked id=id onChange=@onChange)
            Label=(component Label for=id)
          )
        }}
      {{/let}}
    {{/let}}
  </div>
</template>;

export default Switch;


---

/**
 * References:
 * - https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/tablist_role
 * - https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
 *
 *
 * Keyboard behaviors (optionally) provided by tabster
 */

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { isDestroyed, isDestroying } from "@ember/destroyable";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { next } from "@ember/runloop";

import { getTabsterAttribute, MoverDirections } from "tabster";

import { uniqueId } from "../utils.ts";
import Portal from "./portal.gts";

import type { TOC } from "@ember/component/template-only";
import type Owner from "@ember/owner";
import type { ComponentLike, WithBoundArgs } from "@glint/template";

const UNSET = Symbol.for("ember-primitives:tabs:unset");

const TABSTER_CONFIG = getTabsterAttribute(
  {
    mover: {
      direction: MoverDirections.Both,
      cyclic: true,
      memorizeCurrent: true,
    },
    deloser: {},
  },
  true,
);

const TabLink: TOC<{
  Element: HTMLAnchorElement;
  Args: {
    /**
     * @internal
     * for linking of aria
     */
    id: string;
    /**
     * @internal
     * for linking of aria
     */
    panelId: string;
  };
  Blocks: { default: [] };
}> = <template>
  <a href="##missing##" ...attributes role="tab" aria-controls={{@panelId}} id={{@id}}>
    {{yield}}
  </a>
</template>;

export type ButtonType = ComponentLike<ButtonSignature>;
export interface ButtonSignature {
  Element: HTMLButtonElement;
  Blocks: {
    default: [];
  };
}

const TabButton: TOC<{
  Element: HTMLButtonElement;
  Args: {
    /**
     * @internal
     * for linking of aria
     */
    id: string;
    /**
     * @internal
     * for linking of aria
     */
    panelId: string;

    /**
     * @internal
     * for managing state
     */
    handleClick: () => void;

    /**
     * @internal
     * for managing state
     */
    value: string | undefined;

    /**
     * @internal
     */
    state: TabState;
  };
  Blocks: {
    default: [];
  };
}> = <template>
  <button
    ...attributes
    role="tab"
    type="button"
    aria-controls={{@panelId}}
    aria-selected={{String (@state.isActive @id @value)}}
    id={{@id}}
    {{on "click" @handleClick}}
    {{! The Types for modifier are wrong }}
    {{! @glint-expect-error}}
    {{(if @state.isAutomatic (modifier on "focus" @handleClick))}}
  >
    {{yield}}
  </button>
</template>;

export type ContentType = ComponentLike<ContentSignature>;
export interface ContentSignature {
  /**
   * the [role=tabpanel] element
   */
  Element: HTMLDivElement;
  Blocks: {
    default: [];
  };
}

const TabContent: TOC<{
  Element: HTMLDivElement;
  Args: {
    /**
     * @internal
     * for linking of aria
     */
    id: string;
    /**
     * @internal
     * for linking of aria
     */
    tabId: string;
    /**
     * @internal
     */
    state: TabState;
  };
  Blocks: {
    default: [];
  };
}> = <template>
  <Portal @to="#{{@state.tabpanelId}}" @append={{true}}>
    {{#if (@state.isActive @tabId)}}
      <div ...attributes role="tabpanel" aria-labelledby={{@tabId}} id={{@id}}>
        {{yield}}
      </div>
    {{/if}}
  </Portal>
</template>;

function isString(x: unknown): x is string {
  return typeof x === "string";
}

function makeTab(tabButton: any, tabLink: any): any {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
  tabButton.Link = tabLink;

  return tabButton;
}

export type ContainerType = ComponentLike<ContainerSignature>;
export type ContainerSignature =
  | {
      Blocks: {
        default: [];
      };
    }
  | {
      Args: {
        label: string | ComponentLike;
        content: string | ComponentLike;
      };
    }
  | {
      Args: {
        label: string | ComponentLike;
      };
      Blocks: {
        /**
         * The content for the tab
         */
        default: [];
      };
    };

class TabContainer extends Component<{
  Args: {
    /**
     * @internal
     */
    state: TabState;

    /**
     * When opting for a "controlled component",
     * the value will be needed to make sense of the selected tab.
     *
     * The default value used for communication within the Tabs component (and eventually emitted via the @onChange argument) is a unique random id.
     * So while that could still be used for controlling the tabs component, it may be more easy to grok with user-managed values.
     */
    value?: string;

    /**
     * optional user-passable label
     */
    label?: string | ComponentLike;

    /**
     * optional user-passable content.
     */
    content?: string | ComponentLike;
  };
  Blocks: {
    default: [
      Label: WithBoundArgs<typeof TabButton, "state" | "id" | "panelId" | "handleClick" | "value">,
      Content: WithBoundArgs<typeof TabContent, "state" | "id" | "tabId">,
    ];
  };
}> {
  id = `ember-primitives__tab-${uniqueId()}`;

  get tabId() {
    return `${this.id}__tab`;
  }

  get panelId() {
    return `${this.id}__panel`;
  }

  get label() {
    return this.args.label ?? this.tabId;
  }

  <template>
    {{#if @label}}
      <TabButton
        @state={{@state}}
        @id={{this.tabId}}
        @value={{@value}}
        @panelId={{this.panelId}}
        @handleClick={{fn @state.handleChange this.tabId @value}}
      >
        {{#if (isString @label)}}
          {{@label}}
        {{else}}
          <@label />
        {{/if}}
      </TabButton>

      <TabContent @state={{@state}} @id={{this.panelId}} @tabId={{this.tabId}}>
        {{#if @content}}
          {{#if (isString @content)}}
            {{@content}}
          {{else}}
            <@content />
          {{/if}}
        {{else}}
          {{yield}}
        {{/if}}
      </TabContent>
    {{else}}
      {{yield
        (makeTab
          (component
            TabButton
            state=@state
            value=@value
            id=this.tabId
            panelId=this.panelId
            handleClick=(fn @state.handleChange this.tabId @value)
          )
          (component TabLink state=@state id=this.tabId panelId=this.panelId)
        )
        (component TabContent state=@state id=this.panelId tabId=this.tabId)
      }}
    {{/if}}
  </template>
}

const Label: TOC<{
  /**
   * The label wiring (id, aria, etc) are handled for you.
   * If you'd like to use a heading element (h3, etc), place that in the block content
   * when invoking this Label component.
   */
  Element: null;
  Args: {
    /**
     * @internal
     */
    state: TabState;
  };
  Blocks: { default: [] };
}> = <template>
  <Portal @to="#{{@state.labelId}}">
    {{yield}}
  </Portal>
</template>;

export interface Signature {
  /**
   * The wrapping element for the overall Tabs component.
   * This should be used for styling the layout of the tabs.
   */
  Element: HTMLDivElement;
  Args: {
    /**
     * Sets the active tab.
     * If not passed, the first tab will be selected
     */
    activeTab?: string;

    /**
     * Optional label for the overall TabList
     */
    label?: string | ComponentLike;

    /**
     * When the tab changes, this function will be called.
     * The function receives both the newly selected tab as well as the previous tab.
     *
     * However, if the tabs are not configured with names, these values will be null.
     */
    onChange?: (selectedTab: string, previousTab: string | null) => void;

    /**
     * When activationMode is set to "automatic", tabs are activated when receiving focus. When set to "manual", tabs are activated when clicked (or when "enter" is pressed via the keyboard).
     */
    activationMode?: "automatic" | "manual";
  };
  Blocks: {
    default: [
      Tab: WithBoundArgs<typeof TabContainer, "state"> & {
        Label: WithBoundArgs<typeof Label, "state">;
      },
    ];
  };
}

/**
 * We're doing old skool hax with this, so we don't need to care about what the types think, really
 */
function makeAPI(tabContainer: any, labelComponent: any): any {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
  tabContainer.Label = labelComponent;

  return tabContainer;
}

import { buildWaiter } from "@ember/test-waiters";

const stateWaiter = buildWaiter("ember-primitives:tabs");

/**
 * State bucket passed around to all the sub-components.
 *
 * Sort of a "Context", but with a bit of prop-drilling (which is more efficient than dom-context)
 */
class TabState {
  declare args: {
    activeTab?: string;
    activationMode?: "automatic" | "manual";
    onChange?: (selected: string, previous: string | null) => void;
  };

  @tracked _active: string | null = null;

  @tracked _label: string | undefined;

  #first: string | null = null;
  id: string;
  labelId: string;
  tabpanelId: string;
  #token: unknown;

  constructor(args: { activeTab?: string; onChange?: () => void }) {
    this.args = args;

    this.id = `ember-primitives-${uniqueId()}`;
    this.labelId = `${this.id}__label`;
    this.tabpanelId = `${this.id}__tabpanel`;
  }

  get activationMode() {
    return this.args.activationMode ?? "automatic";
  }

  get isAutomatic() {
    return this.activationMode === "automatic";
  }

  /**
   * This function relies on the fact that during rendering,
   * the first component to be rendered will be first,
   * and it will be the one to set the secret first value,
   * which means all other tabs will not be first.
   *
   */
  isActive = (tabId: string, tabValue: undefined | string) => {
    /**
     * When users pass the @value to a tab, we use that for managing
     * the "active state" instead of the DOM ID.
     *
     * NOTE: DOM IDs must be unique across the whole document, but @value
     *     does not need to be unqiue.
     *          `@value` *should* be unique for the Tabs component though
     */
    const isSelected = (x: string) => {
      if (tabValue) return x === tabValue;

      return x === tabId;
    };

    if (this.active === UNSET) {
      if (this.#first) return isSelected(this.#first);

      this.#first = tabValue ?? tabId;
      this.#token = stateWaiter.beginAsync();

      // eslint-disable-next-line ember/no-runloop
      next(() => {
        if (!this.#token) return;
        stateWaiter.endAsync(this.#token);
        if (this._active) return;
        if (isDestroyed(this) || isDestroying(this)) return;

        this._label = tabValue ?? tabId;
      });

      return true;
    }

    return isSelected(this.active);
  };

  get active() {
    return this._active ?? this.args.activeTab ?? UNSET;
  }

  get activeLabel() {
    /**
     * This is only needed during the first set
     * because we prioritize rendering first, and then updating metadata later
     * (next render)
     *
     * NOTE: this does not mean that the a11y tree is updated later.
     *       it is correct on initial render
     */
    if (this._label) {
      return this._label;
    }

    if (this.active === UNSET) {
      return "Pending";
    }

    return this.active;
  }

  handleChange = (tabId: string, tabValue: string | undefined) => {
    const previous = this.active;
    const next = tabValue ?? tabId;

    // No change, no need to be noisy
    if (next === previous) return;

    this._active = this._label = next;

    this.args.onChange?.(next, previous === UNSET ? null : previous);
  };
}

export class Tabs extends Component<Signature> {
  state: TabState;

  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
  constructor(owner: Owner, args: {}) {
    super(owner, args);

    this.state = new TabState(args);
  }

  <template>
    <div class="ember-primitives__tabs" ...attributes data-active={{this.state.activeLabel}}>
      {{! This element will be portaled in to and replaced if tabs.Label is invoked }}
      <div class="ember-primitives__tabs__label" id={{this.state.labelId}}>
        {{#if (isString @label)}}
          {{@label}}
        {{else}}
          <@label />
        {{/if}}
      </div>
      <div
        class="ember-primitives__tabs__tablist"
        role="tablist"
        aria-labelledby={{this.state.labelId}}
        data-tabster={{TABSTER_CONFIG}}
      >
        {{yield
          (makeAPI (component TabContainer state=this.state) (component Label state=this.state))
        }}
      </div>
      {{!
        Tab's contents are portaled in to this element
      }}
      <div class="ember-primitives__tabs__tabpanel" id={{this.state.tabpanelId}}></div>
    </div>
  </template>
}


---

import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { hash } from "@ember/helper";

import { getTabsterAttribute, MoverDirections } from "tabster";
import { TrackedSet } from "tracked-built-ins";
// The consumer will need to provide types for tracked-toolbox.
// Or.. better yet, we PR to trakcked-toolbox to provide them
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { localCopy } from "tracked-toolbox";

import { Toggle } from "./toggle.gts";

import type { ComponentLike } from "@glint/template";

const TABSTER_CONFIG = getTabsterAttribute(
  {
    mover: {
      direction: MoverDirections.Both,
      cyclic: true,
    },
  },
  true,
);

export interface ItemSignature<Value = any> {
  /**
   * The button element will have aria-pressed="true" on it when the button is in the pressed state.
   */
  Element: HTMLButtonElement;
  Args: {
    /**
     * When used in a group of Toggles, this option will be helpful to
     * know which toggle was pressed if you're using the same @onChange
     * handler for multiple toggles.
     */
    value?: Value;
  };
  Blocks: {
    default: [
      /**
       * the current pressed state of the toggle button
       *
       * Useful when using the toggle button as an uncontrolled component
       */
      pressed: boolean,
    ];
  };
}

export type Item<Value = any> = ComponentLike<ItemSignature<Value>>;

export interface SingleSignature<Value> {
  Element: HTMLDivElement;
  Args: {
    /**
     * Optionally set the initial toggle state
     */
    value?: Value;
    /**
     * Callback for when the toggle-group's state is changed.
     *
     * Can be used to control the state of the component.
     *
     *
     * When none of the toggles are selected, undefined will be passed.
     */
    onChange?: (value: Value | undefined) => void;
  };
  Blocks: {
    default: [
      {
        /**
         * The Toggle Switch
         */
        Item: Item;
      },
    ];
  };
}

export interface MultiSignature<Value = any> {
  Element: HTMLDivElement;
  Args: {
    /**
     * Optionally set the initial toggle state
     */
    value?: Value[] | Set<Value> | Value;
    /**
     * Callback for when the toggle-group's state is changed.
     *
     * Can be used to control the state of the component.
     *
     *
     * When none of the toggles are selected, undefined will be passed.
     */
    onChange?: (value: Set<Value>) => void;
  };
  Blocks: {
    default: [
      {
        /**
         * The Toggle Switch
         */
        Item: Item;
      },
    ];
  };
}

interface PrivateSingleSignature<Value = any> {
  Element: HTMLDivElement;
  Args: {
    type?: "single";

    /**
     * Optionally set the initial toggle state
     */
    value?: Value;
    /**
     * Callback for when the toggle-group's state is changed.
     *
     * Can be used to control the state of the component.
     *
     *
     * When none of the toggles are selected, undefined will be passed.
     */
    onChange?: (value: Value | undefined) => void;
  };
  Blocks: {
    default: [
      {
        Item: Item;
      },
    ];
  };
}

interface PrivateMultiSignature<Value = any> {
  Element: HTMLDivElement;
  Args: {
    type: "multi";
    /**
     * Optionally set the initial toggle state
     */
    value?: Value[] | Set<Value> | Value;
    /**
     * Callback for when the toggle-group's state is changed.
     *
     * Can be used to control the state of the component.
     *
     *
     * When none of the toggles are selected, undefined will be passed.
     */
    onChange?: (value: Set<Value>) => void;
  };
  Blocks: {
    default: [
      {
        Item: Item;
      },
    ];
  };
}

function isMulti(x: "single" | "multi" | undefined): x is "multi" {
  return x === "multi";
}

export class ToggleGroup<Value = any> extends Component<
  PrivateSingleSignature<Value> | PrivateMultiSignature<Value>
> {
  // See: https://github.com/typed-ember/glint/issues/715
  <template>
    {{#if (isMulti this.args.type)}}
      <MultiToggleGroup
        @value={{this.args.value}}
        @onChange={{this.args.onChange}}
        ...attributes
        as |x|
      >
        {{yield x}}
      </MultiToggleGroup>
    {{else}}
      <SingleToggleGroup
        @value={{this.args.value}}
        @onChange={{this.args.onChange}}
        ...attributes
        as |x|
      >
        {{yield x}}
      </SingleToggleGroup>
    {{/if}}
  </template>
}

class SingleToggleGroup<Value = any> extends Component<SingleSignature<Value>> {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
  @localCopy("args.value") activePressed?: Value;

  handleToggle = (value: Value) => {
    if (this.activePressed === value) {
      this.activePressed = undefined;

      return;
    }

    this.activePressed = value;

    this.args.onChange?.(this.activePressed);
  };

  isPressed = (value: Value | undefined) => value === this.activePressed;

  <template>
    <div data-tabster={{TABSTER_CONFIG}} ...attributes>
      {{yield (hash Item=(component Toggle onChange=this.handleToggle isPressed=this.isPressed))}}
    </div>
  </template>
}

class MultiToggleGroup<Value = any> extends Component<MultiSignature<Value>> {
  /**
   * Normalizes @value to a Set
   * and makes sure that even if the input Set is reactive,
   * we don't mistakenly dirty it.
   */
  @cached
  get activePressed(): TrackedSet<Value> {
    const value = this.args.value;

    if (!value) {
      return new TrackedSet();
    }

    if (Array.isArray(value)) {
      return new TrackedSet(value);
    }

    if (value instanceof Set) {
      return new TrackedSet(value);
    }

    return new TrackedSet([value]);
  }

  handleToggle = (value: Value) => {
    if (this.activePressed.has(value)) {
      this.activePressed.delete(value);
    } else {
      this.activePressed.add(value);
    }

    this.args.onChange?.(new Set<Value>(this.activePressed.values()));
  };

  isPressed = (value: Value) => this.activePressed.has(value);

  <template>
    <div data-tabster={{TABSTER_CONFIG}} ...attributes>
      {{yield (hash Item=(component Toggle onChange=this.handleToggle isPressed=this.isPressed))}}
    </div>
  </template>
}


---

// import Component from '@glimmer/component';
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";

import { cell } from "ember-resources";

import { toggleWithFallback } from "./-private/utils.ts";

import type { TOC } from "@ember/component/template-only";

export interface Signature<Value = any> {
  Element: HTMLButtonElement;
  Args: {
    /**
     * The pressed-state of the toggle.
     *
     * Can be used to control the state of the component.
     */
    pressed?: boolean;
    /**
     * Callback for when the toggle's state is changed.
     *
     * Can be used to control the state of the component.
     *
     * if a `@value` is passed to this `<Toggle>`, that @value will
     * be passed to the `@onChange` handler.
     *
     * This can be useful when using the same function for the `@onChange`
     * handler with multiple `<Toggle>` components.
     */
    onChange?: (value: Value | undefined, pressed: boolean) => void;

    /**
     * When used in a group of Toggles, this option will be helpful to
     * know which toggle was pressed if you're using the same @onChange
     * handler for multiple toggles.
     */
    value?: Value;

    /**
     * When controlling state in a wrapping component, this function can be used in conjunction with `@value` to determine if this `<Toggle>` should appear pressed.
     */
    isPressed?: (value?: Value) => boolean;
  };
  Blocks: {
    default: [
      /**
       * the current pressed state of the toggle button
       *
       * Useful when using the toggle button as an uncontrolled component
       */
      pressed: boolean,
    ];
  };
}

function isPressed(
  pressed?: boolean,
  value?: unknown,
  isPressed?: (value?: unknown) => boolean,
): boolean {
  if (!value) return Boolean(pressed);
  if (!isPressed) return Boolean(pressed);

  return isPressed(value);
}

export const Toggle: TOC<Signature> = <template>
  {{#let (cell (isPressed @pressed @value @isPressed)) as |pressed|}}
    <button
      type="button"
      aria-pressed="{{pressed.current}}"
      {{on "click" (fn toggleWithFallback pressed.toggle @onChange @value)}}
      ...attributes
    >
      {{yield pressed.current}}
    </button>
  {{/let}}
</template>;

export default Toggle;


---

span[data-prim-avatar]:has(img[alt="__missing__"])::after,
[aria-label="__missing__"] {
  border: red;
}
label [aria-label="__missing__"] {
  border: unset;
}

/**
 * ExternalLink
 */
a[href='##missing##'],
/**
 * Avatar
 */
span[data-prim-avatar]:has(img[alt='__missing__'])::after,
/**
 * Switch
 */
div[data-prim-switch]:has(input[role="switch"]):not(:has(label)) input[role="switch"] {
  position: relative;
  border: 1px solid black;
  padding: 0.125rem 0.25rem;
  border-radius: 0.125rem;
  min-width: 10px;
}

:is(
  /**
   * ExternalLink
   */
  a[href='##missing##'],
  /**
   * Avatar
   */
  span[data-prim-avatar]:has(img[alt='__missing__'])::after,
  /**
   * Switch
   */
  div[data-prim-switch]:not(:has(label)):has(input[role="switch"]) input[role="switch"]
)::after {
  color: red;
  position: absolute;
  font-size: 0.75rem;
  font-family: monospace;
  background: black;
  padding: 0.125rem 0.25rem;
  display: flex;
  border-radius: 0.125rem;
  transform: translate(0.5rem, 1rem);
  left: 0;
  bottom: 0;
  width: max-content;
  z-index: 10000000000000000;
}

a[href="##missing##"]::after {
  content: "empty href";
}

span[data-prim-avatar]:has(img[alt="__missing__"])::after {
  content: "missing alt";
}

div[data-prim-switch]:not(:has(label)):has(input[role="switch"]) input[role="switch"]::after {
  content: "missing label";
}

@media (prefers-color-scheme: light) {
  :is(
    a[href="##missing##"],
    span[data-prim-avatar]:has(img[alt="__missing__"]),
    div[data-prim-switch]:has(input[role="switch"]):not(:has(label)) input[role="switch"]
  ) {
    border-color: black;
  }
  :is(
    a[href="##missing##"],
    span[data-prim-avatar]:has(img[alt="__missing__"]),
    div[data-prim-switch]:not(:has(label)):has(input[role="switch"]) input[role="switch"]
  ):after {
    background: white;
    border: 1px solid black;
    color: darkred;
  }
}

@media (prefers-color-scheme: dark) {
  :is(
    a[href="##missing##"],
    span[data-prim-avatar]:has(img[alt="__missing__"]),
    div[data-prim-switch]:has(input[role="switch"]):not(:has(label)) input[role="switch"]
  ) {
    border-color: red;
  }
  :is(
    a[href="##missing##"],
    span[data-prim-avatar]:has(img[alt="__missing__"]),
    div[data-prim-switch]:not(:has(label)):has(input[role="switch"]) input[role="switch"]
  ):after {
    background: #222;
    border: 1px solid red;
    color: red;
  }
}


---

import './violations.css';


---

/* See: https://github.com/twbs/bootstrap/blob/main/scss/mixins/_visually-hidden.scss */
.ember-primitives__visually-hidden,
[visually-hidden] {
  position: absolute;
  border: 0;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  word-wrap: normal;
}


---

import "./visually-hidden.css";

import type { TOC } from "@ember/component/template-only";

export const VisuallyHidden: TOC<{
  Element: HTMLSpanElement;
  Blocks: {
    /**
     * Content to hide visually
     */
    default: [];
  };
}> = <template>
  <span class="ember-primitives__visually-hidden" ...attributes>{{yield}}</span>
</template>;


---

export { Zoetrope } from './zoetrope/index.gts';
export { default } from './zoetrope/index.gts';
export type { Signature } from './zoetrope/types.ts';


---

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";

import { modifier as eModifier } from "ember-modifier";

import { anchorTo } from "./modifier.ts";

import type { Signature as ModifierSignature } from "./modifier.ts";
import type { MiddlewareState } from "@floating-ui/dom";
import type { ModifierLike } from "@glint/template";

type ModifierArgs = ModifierSignature["Args"]["Named"];

interface ReferenceSignature {
  Element: HTMLElement | SVGElement;
}

export interface Signature {
  Args: {
    /**
     * Additional middleware to pass to FloatingUI.
     *
     * See: [The middleware docs](https://floating-ui.com/docs/middleware)
     */
    middleware?: ModifierArgs["middleware"];
    /**
     * Where to place the floating element relative to its reference element.
     * The default is 'bottom'.
     *
     * See: [The placement docs](https://floating-ui.com/docs/computePosition#placement)
     */
    placement?: ModifierArgs["placement"];
    /**
     * This is the type of CSS position property to use.
     * By default this is 'fixed', but can also be 'absolute'.
     *
     * See: [The strategy docs](https://floating-ui.com/docs/computePosition#strategy)
     */
    strategy?: ModifierArgs["strategy"];
    /**
     * Options to pass to the [flip middleware](https://floating-ui.com/docs/flip)
     */
    flipOptions?: ModifierArgs["flipOptions"];
    /**
     * Options to pass to the [hide middleware](https://floating-ui.com/docs/hide)
     */
    hideOptions?: ModifierArgs["hideOptions"];
    /**
     * Options to pass to the [shift middleware](https://floating-ui.com/docs/shift)
     */
    shiftOptions?: ModifierArgs["shiftOptions"];
    /**
     * Options to pass to the [offset middleware](https://floating-ui.com/docs/offset)
     */
    offsetOptions?: ModifierArgs["offsetOptions"];
  };
  Blocks: {
    default: [
      /**
       * A modifier to apply to the _reference_ element.
       * This is what the floating element will use to anchor to.
       *
       * Example
       * ```gjs
       * import { FloatingUI } from 'ember-primitives/floating-ui';
       *
       * <template>
       *   <FloatingUI as |reference floating|>
       *     <button {{reference}}> ... </button>
       *     ...
       *   </FloatingUI>
       * </template>
       * ```
       */
      reference: ModifierLike<ReferenceSignature>,
      /**
       * A modifier to apply to the _floating_ element.
       * This is what will anchor to the reference element.
       *
       * Example
       * ```gjs
       * import { FloatingUI } from 'ember-primitives/floating-ui';
       *
       * <template>
       *   <FloatingUI as |reference floating|>
       *     <button {{reference}}> ... </button>
       *     <menu {{floating}}> ... </menu>
       *   </FloatingUI>
       * </template>
       * ```
       */
      floating:
        | undefined
        | ModifierLike<{
            Element: HTMLElement;
            Args: {
              Named: ModifierArgs;
            };
          }>,
      /**
       * Special utilities for advanced usage
       */
      util: {
        /**
         * If you want to have a single modifier with custom behavior
         * on your reference element, you may use this `setReference`
         * function to set the reference, rather than having multiple modifiers
         * on that element.
         */
        setReference: (element: HTMLElement | SVGElement) => void;
        /**
         * Metadata exposed from floating-ui.
         * Gives you x, y position, among other things.
         */
        data?: MiddlewareState;
      },
    ];
  };
}

const ref = eModifier<{
  Element: HTMLElement | SVGElement;
  Args: {
    Positional: [setRef: (element: HTMLElement | SVGElement) => void];
  };
}>((element: HTMLElement | SVGElement, positional) => {
  const fn = positional[0];

  fn(element);
});

/**
 * A component that provides no DOM and yields two modifiers for creating
 * creating floating uis, such as menus, popovers, tooltips, etc.
 * This component currently uses [Floating UI](https://floating-ui.com/)
 * but will be switching to [CSS Anchor Positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning) when that lands.
 *
 * Example usage:
 * ```gjs
 * import { FloatingUI } from 'ember-primitives/floating-ui';
 *
 * <template>
 *   <FloatingUI as |reference floating|>
 *     <button {{reference}}> ... </button>
 *     <menu {{floating}}> ... </menu>
 *   </FloatingUI>
 * </template>
 * ```
 */
export class FloatingUI extends Component<Signature> {
  @tracked reference?: HTMLElement | SVGElement = undefined;
  @tracked data?: MiddlewareState = undefined;

  setData: ModifierArgs["setData"] = (data) => (this.data = data);

  setReference = (element: HTMLElement | SVGElement) => {
    this.reference = element;
  };

  <template>
    {{#let
      (modifier
        anchorTo
        flipOptions=@flipOptions
        hideOptions=@hideOptions
        middleware=@middleware
        offsetOptions=@offsetOptions
        placement=@placement
        shiftOptions=@shiftOptions
        strategy=@strategy
        setData=this.setData
      )
      as |prewiredAnchorTo|
    }}
      {{#let (if this.reference (modifier prewiredAnchorTo this.reference)) as |floating|}}
        {{! @glint-nocheck -- Excessively deep, possibly infinite }}
        {{yield
          (modifier ref this.setReference)
          floating
          (hash setReference=this.setReference data=this.data)
        }}
      {{/let}}
    {{/let}}
  </template>
}


---

import type { Middleware } from '@floating-ui/dom';

export function exposeMetadata(): Middleware {
  return {
    name: 'metadata',
    fn: (data) => {
      // https://floating-ui.com/docs/middleware#always-return-an-object
      return {
        data,
      };
    },
  };
}


---

import { assert } from '@ember/debug';

import { autoUpdate, computePosition, flip, hide, offset, shift } from '@floating-ui/dom';
import { modifier as eModifier } from 'ember-modifier';

import { exposeMetadata } from './middleware.ts';

import type {
  FlipOptions,
  HideOptions,
  Middleware,
  OffsetOptions,
  Placement,
  ShiftOptions,
  Strategy,
} from '@floating-ui/dom';

export interface Signature {
  /**
   *
   */
  Element: HTMLElement;
  Args: {
    Positional: [
      /**
       * What do use as the reference element.
       * Can be a selector or element instance.
       *
       * Example:
       * ```gjs
       * import { anchorTo } from 'ember-primitives/floating-ui';
       *
       * <template>
       *   <div id="reference">...</div>
       *   <div {{anchorTo "#reference"}}> ... </div>
       * </template>
       * ```
       */
      referenceElement: string | HTMLElement | SVGElement,
    ];
    Named: {
      /**
       * This is the type of CSS position property to use.
       * By default this is 'fixed', but can also be 'absolute'.
       *
       * See: [The strategy docs](https://floating-ui.com/docs/computePosition#strategy)
       */
      strategy?: Strategy;
      /**
       * Options to pass to the [offset middleware](https://floating-ui.com/docs/offset)
       */
      offsetOptions?: OffsetOptions;
      /**
       * Where to place the floating element relative to its reference element.
       * The default is 'bottom'.
       *
       * See: [The placement docs](https://floating-ui.com/docs/computePosition#placement)
       */
      placement?: Placement;
      /**
       * Options to pass to the [flip middleware](https://floating-ui.com/docs/flip)
       */
      flipOptions?: FlipOptions;
      /**
       * Options to pass to the [shift middleware](https://floating-ui.com/docs/shift)
       */
      shiftOptions?: ShiftOptions;
      /**
       * Options to pass to the [hide middleware](https://floating-ui.com/docs/hide)
       */
      hideOptions?: HideOptions;
      /**
       * Additional middleware to pass to FloatingUI.
       *
       * See: [The middleware docs](https://floating-ui.com/docs/middleware)
       */
      middleware?: Middleware[];
      /**
       * A callback for when data changes about the position / placement / etc
       * of the floating element.
       */
      setData?: Middleware['fn'];
    };
  };
}

/**
 * A modifier to apply to the _floating_ element.
 * This is what will anchor to the reference element.
 *
 * Example
 * ```gjs
 * import { anchorTo } from 'ember-primitives/floating-ui';
 *
 * <template>
 *   <button id="my-button"> ... </button>
 *   <menu {{anchorTo "#my-button"}}> ... </menu>
 * </template>
 * ```
 */
export const anchorTo = eModifier<Signature>(
  (
    floatingElement,
    [_referenceElement],
    {
      strategy = 'fixed',
      offsetOptions = 0,
      placement = 'bottom',
      flipOptions,
      shiftOptions,
      middleware = [],
      setData,
    }
  ) => {
    const referenceElement: null | HTMLElement | SVGElement =
      typeof _referenceElement === 'string'
        ? document.querySelector(_referenceElement)
        : _referenceElement;

    assert(
      'no reference element defined',
      referenceElement instanceof HTMLElement || referenceElement instanceof SVGElement
    );

    assert(
      'no floating element defined',
      floatingElement instanceof HTMLElement || _referenceElement instanceof SVGElement
    );

    assert(
      'reference and floating elements cannot be the same element',
      floatingElement !== _referenceElement
    );

    assert('@middleware must be an array of one or more objects', Array.isArray(middleware));

    Object.assign(floatingElement.style, {
      position: strategy,
      top: '0',
      left: '0',
    });

    const update = async () => {
      const { middlewareData, x, y } = await computePosition(referenceElement, floatingElement, {
        middleware: [
          offset(offsetOptions),
          flip(flipOptions),
          shift(shiftOptions),
          ...middleware,
          hide({ strategy: 'referenceHidden' }),
          hide({ strategy: 'escaped' }),
          exposeMetadata(),
        ],
        placement,
        strategy,
      });

      const referenceHidden = middlewareData.hide?.referenceHidden;

      Object.assign(floatingElement.style, {
        top: `${y}px`,
        left: `${x}px`,
        margin: 0,
        visibility: referenceHidden ? 'hidden' : 'visible',
      });

      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
      void setData?.(middlewareData['metadata']);
    };

    void update();

    // eslint-disable-next-line @typescript-eslint/no-misused-promises
    const cleanup = autoUpdate(referenceElement, floatingElement, update);

    /**
     * in the function-modifier manager, teardown of the previous modifier
     * occurs before setup of the next
     * https://github.com/ember-modifier/ember-modifier/blob/main/ember-modifier/src/-private/function-based/modifier-manager.ts#L58
     */
    return cleanup;
  }
);


---

/**
 * Initial inspo:
 * - https://github.com/ef4/ember-set-body-class/blob/master/addon/services/body-class.js
 * - https://github.com/ef4/ember-set-body-class/blob/master/addon/helpers/set-body-class.js
 */
import Helper from '@ember/component/helper';
import { buildWaiter } from '@ember/test-waiters';

const waiter = buildWaiter('ember-primitives:body-class:raf');

let id = 0;
const registrations = new Map<number, string[]>();
let previousRegistrations: string[] = [];

function classNames(): string[] {
  const allNames = new Set<string>();

  for (const classNames of registrations.values()) {
    for (const className of classNames) {
      allNames.add(className);
    }
  }

  return [...allNames];
}

let frame: number;
let waiterToken: unknown;

function queueUpdate() {
  waiterToken ||= waiter.beginAsync();

  cancelAnimationFrame(frame);
  frame = requestAnimationFrame(() => {
    updateBodyClass();
    waiter.endAsync(waiterToken);
    waiterToken = undefined;
  });
}

/**
 * This should only add/remove classes that we tried to maintain via the body-class helper.
 *
 * Folks can set classes in their html and we don't want to mess with those
 */
function updateBodyClass() {
  const toAdd = classNames();

  for (const name of previousRegistrations) {
    document.body.classList.remove(name);
  }

  for (const name of toAdd) {
    document.body.classList.add(name);
  }

  previousRegistrations = toAdd;
}

export interface Signature {
  Args: {
    Positional: [
      /**
       * a space-delimited list of classes to apply when this helper is called.
       *
       * When the helper is removed from rendering, the clasess will be removed as well.
       */
      classes: string,
    ];
  };
  /**
   * This helper returns nothing, as it is a side-effect that mutates and manages external state.
   */
  Return: undefined;
}

export default class BodyClass extends Helper<Signature> {
  localId = id++;

  compute([classes]: [string]): undefined {
    const classNames = classes ? classes.split(/\s+/) : [];

    registrations.set(this.localId, classNames);

    queueUpdate();
  }

  willDestroy() {
    registrations.delete(this.localId);
    queueUpdate();
  }
}

export const bodyClass = BodyClass;


---

import Helper from '@ember/component/helper';
import { assert } from '@ember/debug';
import { service } from '@ember/service';

import { handle } from '../proper-links.ts';

import type RouterService from '@ember/routing/router-service';

export interface Signature {
  Args: {
    Positional: [href: string];
    Named: {
      includeActiveQueryParams?: boolean | string[];
      activeOnSubPaths?: boolean;
    };
  };
  Return: {
    isExternal: boolean;
    isActive: boolean;
    handleClick: (event: MouseEvent) => void;
  };
}

export default class Link extends Helper<Signature> {
  @service declare router: RouterService;

  compute(
    [href]: [href: string],
    {
      includeActiveQueryParams = false,
      activeOnSubPaths = false,
    }: { includeActiveQueryParams?: boolean | string[]; activeOnSubPaths?: boolean }
  ) {
    assert('href was not passed in', href);

    const router = this.router;
    const handleClick = (event: MouseEvent) => {
      assert('[BUG]', event.currentTarget instanceof HTMLAnchorElement);

      handle(router, event.currentTarget, [], event);
    };

    return {
      isExternal: isExternal(href),
      get isActive() {
        return isActive(router, href, includeActiveQueryParams, activeOnSubPaths);
      },
      handleClick,
    };
  }
}

export const link = Link;

export function isExternal(href: string) {
  if (!href) return false;
  if (href.startsWith('#')) return false;
  if (href.startsWith('/')) return false;

  return location.origin !== new URL(href).origin;
}

export function isActive(
  router: RouterService,
  href: string,
  includeQueryParams?: boolean | string[],
  activeOnSubPaths?: boolean
) {
  if (!includeQueryParams) {
    /**
     * is Active doesn't understand `href`, so we have to convert to RouteInfo-esque
     */
    const info = router.recognize(href);

    if (info) {
      const dynamicSegments = getParams(info);
      const routeName = activeOnSubPaths ? info.name.replace(/\.index$/, '') : info.name;

      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
      return router.isActive(routeName, ...dynamicSegments);
    }

    return false;
  }

  const url = new URL(href, location.origin);
  const hrefQueryParams = new URLSearchParams(url.searchParams);
  const hrefPath = url.pathname;

  const currentPath = router.currentURL?.split('?')[0];

  if (!currentPath) return false;

  if (activeOnSubPaths ? !currentPath.startsWith(hrefPath) : hrefPath !== currentPath) return false;

  const currentQueryParams = router.currentRoute?.queryParams;

  if (!currentQueryParams) return false;

  if (includeQueryParams === true) {
    return Object.entries(currentQueryParams).every(([key, value]) => {
      return hrefQueryParams.get(key) === value;
    });
  }

  return includeQueryParams.every((key) => {
    return hrefQueryParams.get(key) === currentQueryParams[key];
  });
}

type RouteInfo = ReturnType<RouterService['recognize']>;

export function getParams(currentRouteInfo: RouteInfo) {
  let params: Record<string, unknown>[] = [];

  while (currentRouteInfo?.parent) {
    const currentParams = currentRouteInfo.params;

    params = currentParams ? [currentParams, ...params] : params;
    currentRouteInfo = currentRouteInfo.parent;
  }

  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
  return params.map(Object.values).flat();
}


---

import Helper from '@ember/component/helper';
import { assert } from '@ember/debug';
import { getOwner } from '@ember/owner';

import type { Registry } from '@ember/service';
import type Service from '@ember/service';

export interface Signature<Key extends keyof Registry> {
  Args: {
    Positional: [Key];
  };
  Return: Registry[Key] & Service;
}

export default class GetService<Key extends keyof Registry> extends Helper<Signature<Key>> {
  compute(positional: [Key]): Registry[Key] & Service {
    const owner = getOwner(this);

    assert(`Could not get owner.`, owner);

    return owner.lookup(`service:${positional[0]}`) as Registry[Key] & Service;
  }
}

export const service = GetService;


---

import { assert } from '@ember/debug';

import { setupTabster as _setupTabster } from '../tabster.ts';

import type Owner from '@ember/owner';

/**
 * Sets up all support utilities for primitive components.
 * Including the tabster root.
 */
async function setup(owner: Owner) {
  await _setupTabster(owner, { setTabsterRoot: false });

  document.querySelector('#ember-testing')?.setAttribute('data-tabster', '{ "root": {} }');
}

/**
 * A QUnit test utility for setting up the tabbing utility that a few of the components in ember-primitive use for providing enhanced keyboard support.
 *
 * ```gjs
 * import { module, test } from 'qunit';
 * import { setupRenderingTest } from 'ember-qunit';
 * import { setupTabster } from 'ember-primitives/test-support';
 *
 * module('your suite', function (hooks) {
 *   setupRenderingTest(hooks);
 *   setupTabster(hooks);
 *
 *   test('your test', async function (assert) {
 *      // ...
 *   });
 * });
 * ```
 *
 * This utility takes no options.
 */
export function setupTabster(hooks: {
  beforeEach: (callback: () => void | Promise<void>) => unknown;
}) {
  hooks.beforeEach(async function (this: { owner: object }) {
    const owner = this.owner;

    assert(
      `Test does not have an owner, be sure to use setupRenderingTest, setupTest, or setupApplicationTest (from ember-qunit (or similar))`,
      owner
    );

    await setup(this.owner as Owner);
  });
}


---

import { assert } from '@ember/debug';
import { find } from '@ember/test-helpers';

type Findable = Parameters<typeof find>[0] | Element;

/**
 * Find an element within a given element that has a shadow-root.
 *
 * If the `root` can't be found, or if there actually is no shadow root,
 * nothing will be returned.
 *
 * ```gjs
 * import { findInShadow } from 'ember-primitives/test-support';
 *
 * // ...
 *
 * test('...', async function (assert) {
 *    // ...
 *    const root = find('div.with-shadowdom');
 *    assert.dom(findInShadow(root, 'h1')).containsText('welcome');
 * });
 * ```
 */
export function findInShadow(root: Findable, query: string) {
  const rootElement = root instanceof Element ? root : find(root);

  return rootElement?.shadowRoot?.querySelector(query);
}

/**
 * Does the element have a shadow root?
 *
 * Using this utility function will only save a few characters over using its implementation directly.
 *
 * ```gjs
 * import { hasShadowRoot } from 'ember-primitives/test-support';
 *
 * // ...
 *
 * test('...', async function (assert) {
 *    // ...
 *    const el = find('div.with-shadowdom');
 *    assert.ok(hasShadowRoot(el), 'expecting el to have a shadow root');
 * });
 * ```
 */
export function hasShadowRoot(el: Element) {
  return Boolean(el.shadowRoot);
}

/**
 * Find an element within `root`, that has a shadow root.
 * The `root` param is optional, and if not provided, all of `#ember-testing` will be searched.
 *
 * This only returns the first-found shadow, so if you want a specifc shadow root,
 * you'll need to narrow down the search by specifying a `root`.
 *
 * ```gjs
 * import { findShadow } from 'ember-primitives/test-support';
 *
 * // ...
 *
 * test('...', async function (assert) {
 *    // ...
 *    const el = findShadow('div.with-shadowdom');
 *    // ...
 * });
 * ```
 */
export function findShadow(root?: Findable) {
  const rootElement = root
    ? root instanceof Element
      ? root
      : find(root)
    : document.getElementById('ember-testing');

  if (!rootElement) return;

  for (const element of rootElement.querySelectorAll('*')) {
    if (element.shadowRoot) {
      return element;
    }
  }
}

/**
 * For the first available shadow root on the page, query in to it, like you would with `querySelector`.
 *
 *
 * ```gjs
 * import { findInFirstShadow } from 'ember-primitives/test-support';
 *
 * // ...
 *
 * test('...', async function (assert) {
 *    // ...
 *    assert.dom(findInFirstShadow('h1')).containsText('welcome');
 * });
 * ```
 *
 * If there are multiple shadow roots on the page / test-render,
 * this is not the utility for you.
 *
 * For querying in specific shadow roots, you'll want to use `findInShadow`
 */
export function findInFirstShadow(query: string) {
  const host = findShadow();

  assert(`No element with a shadow root could be found`, host);

  return findInShadow(host, query);
}


---

import { assert } from '@ember/debug';
import { fillIn, find, settled } from '@ember/test-helpers';

/**
 * Fill the OTP input
 *
 * ```gjs
 * import { fillOTP } from 'ember-primitives/test-support';
 *
 * test('...', async function(assert) {
 *   // ...
 *   await fillOTP('123456');
 *   // ...
 * })
 *
 * ```
 *
 * @param {string} code the code to fill the input(s) with.
 * @param {string} [ selector ] if there are multiple OTP components on a page, this can be used to select one of them.
 */
export async function fillOTP(code: string, selector?: string) {
  const ancestor = selector ? find(selector) : document;

  assert(
    `Could not find ancestor element, does your selector match an existing element?`,
    ancestor
  );

  const fieldset =
    ancestor instanceof HTMLFieldSetElement ? ancestor : ancestor.querySelector('fieldset');

  assert(
    `Could not find containing fieldset element (this holds the OTP Input fields). Was the OTP component rendered?`,
    fieldset
  );

  const inputs = fieldset.querySelectorAll('input');

  assert(
    `code cannot be longer than the available inputs. code is of length ${code.length} but there are ${inputs.length}`,
    code.length <= inputs.length
  );

  const chars = code.split('');

  assert(`OTP Input for index 0 is missing!`, inputs[0]);
  assert(`Character at index 0 is missing`, chars[0]);

  for (let i = 0; i < chars.length; i++) {
    const input = inputs[i];
    const char = chars[i];

    assert(`Input at index ${i} is missing`, input);
    assert(`Character at index ${i} is missing`, char);

    input.value = char;
  }

  await fillIn(inputs[0], chars[0]);

  // Account for out-of-settled-system delay due to RAF debounce.
  await new Promise((resolve) => requestAnimationFrame(resolve));
  await settled();
}


---

import { assert } from '@ember/debug';
import { click, fillIn, find, findAll } from '@ember/test-helpers';

const selectors = {
  root: '.ember-primitives__rating',
  item: '.ember-primitives__rating__item',
  label: '.ember-primitives__rating__label',

  rootData: {
    total: '[data-total]',
    value: '[data-value]',
  },

  itemData: {
    number: '[data-number]',
    readonly: '[data-readonly]',
    selected: '[data-selected]',
    itemPercent: '[data-percent-selected]',
  },
};

const stars = {
  selected: '★',
  unselected: '☆',
};

/**
 * Test utility for interacting with the
 * Rating component.
 *
 * Simulates user behavior and provides high level functions so you don't need to worry about the DOM.
 *
 * Actual elements are not exposed, as the elements are private API.
 * Even as you build a design system, the DOM should not be exposed to your consumers.
 */
export function rating(selector?: string) {
  const root = `${selector ?? ''}${selectors.root}`;

  return new RatingPageObject(root);
}

class RatingPageObject {
  #root: string;

  constructor(root: string) {
    this.#root = root;
  }

  get #rootElement() {
    const element = find(this.#root);

    assert(
      `Could not find the root element for the <Rating> component. Used the selector \`${this.#root}\`. Was it rendered?`,
      element
    );

    return element;
  }

  get #labelElement() {
    const element = find(`${this.#root} ${selectors.label}`);

    assert(`Could not find the label for the <Rating> component. Was it rendered?`, element);

    return element;
  }

  get label() {
    return this.#labelElement.textContent?.replaceAll(/\s+/g, ' ').trim();
  }

  get #starElements() {
    const elements = findAll(`${this.#root} ${selectors.item}`);

    assert(
      `There are no stars/items. Is the <Rating> component misconfigured?`,
      elements.length > 0
    );

    return elements as HTMLElement[];
  }

  get stars() {
    const elements = this.#starElements;

    return elements
      .map((x) => (x.hasAttribute('data-selected') ? stars.selected : stars.unselected))
      .join(' ');
  }

  get starTexts() {
    const elements = this.#starElements;

    return elements.map((x) => x.querySelector('[aria-hidden]')?.textContent?.trim()).join(' ');
  }

  get value() {
    const value = this.#rootElement.getAttribute(`data-value`);

    assert(`data-value attribute is missing on element '${this.#root}'`, value);

    const number = parseFloat(value);

    return number;
  }

  get isReadonly() {
    return this.#starElements.every((x) => x.hasAttribute('data-readonly'));
  }

  async select(stars: number) {
    const root = this.#rootElement;

    const star = root.querySelector(`[data-number="${stars}"] input`);

    if (star) {
      await click(star);

      return;
    }

    /**
     * When we don't have an input, we require an input --
     * which is also the only way we can choose non-integer values.
     *
     * Should be able to be a number input or range input.
     */
    const input = root.querySelector('input[type="number"], input[type="range"]');

    if (input) {
      await fillIn(input, `${stars}`);

      return;
    }

    const available = [...root.querySelectorAll('[data-number]')].map((x) =>
      x.getAttribute('data-number')
    );

    assert(
      `Could not find item/star in <Rating> with value '${stars}' (or a number or range input with the same "name" value). Is the number (${stars}) correct and in-range for this component? The found available values are ${available.join(', ')}.`
    );
  }
}


---

import { assert } from '@ember/debug';
import Router from '@ember/routing/router';

import { properLinks } from '../proper-links.ts';

import type Owner from '@ember/owner';
import type { DSLCallback } from '@ember/routing/lib/dsl';
import type RouterService from '@ember/routing/router-service';

/**
 * Allows setting up routes in tests without the need to scaffold routes in the actual app,
 * allowing for iterating on many different routing scenario / configurations rapidly.
 *
 * Example:
 * ```js
 * import { setupRouting } from 'ember-primitives/test-support';
 *
 *  ...
 *
 * test('my test', async function (assert) {
 *   setupRouting(this.owner, function () {
 *     this.route('foo');
 *     this.route('bar', function () {
 *       this.route('a');
 *       this.route('b');
 *     })
 *   });
 *
 *   await visit('/bar/b');
 * });
 * ```
 *
 */
export function setupRouting(owner: Owner, map: DSLCallback, options?: { rootURL: string }) {
  if (options?.rootURL) {
    assert('rootURL must begin with a forward slash ("/")', options?.rootURL?.startsWith('/'));
  }

  @properLinks
  class TestRouter extends Router {
    rootURL = options?.rootURL ?? '/';
  }

  TestRouter.map(map);

  owner.register('router:main', TestRouter);

  // eslint-disable-next-line ember/no-private-routing-service
  const iKnowWhatIMDoing = owner.lookup('router:main');

  // We need a public testing API for this sort of stuff

  // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
  (iKnowWhatIMDoing as any).setupRouter();
}

/**
 * A small utility that only gives you a _typed_ router service.
 */
export function getRouter(owner: Owner): RouterService {
  return owner.lookup('service:router');
}


---

/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { click } from '@ember/test-helpers';

export class ZoetropeHelper {
  parentSelector = '.ember-primitives__zoetrope';

  constructor(parentSelector?: string) {
    if (parentSelector) {
      this.parentSelector = parentSelector;
    }
  }

  async scrollLeft() {
    await click(`${this.parentSelector} .ember-primitives__zoetrope__controls button:first-child`);
  }

  async scrollRight() {
    await click(`${this.parentSelector} .ember-primitives__zoetrope__controls button:last-child`);
  }

  visibleItems() {
    const zoetropeContent = document.querySelectorAll(
      `${this.parentSelector} .ember-primitives__zoetrope__scroller > *`
    );

    let firstVisibleItemIndex = -1;
    let lastVisibleItemIndex = -1;

    for (let i = 0; i < zoetropeContent.length; i++) {
      const item = zoetropeContent[i]!;
      const rect = item.getBoundingClientRect();
      const parentRect = item.parentElement!.getBoundingClientRect();

      if (rect.right >= parentRect?.left && rect.left <= parentRect?.right) {
        if (firstVisibleItemIndex === -1) {
          firstVisibleItemIndex = i;
        }

        lastVisibleItemIndex = i;
      } else if (firstVisibleItemIndex !== -1) {
        break;
      }
    }

    return Array.from(zoetropeContent).slice(firstVisibleItemIndex, lastVisibleItemIndex + 1);
  }

  visibleItemCount() {
    return this.visibleItems().length;
  }
}


---

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { assert } from "@ember/debug";

import { element } from "ember-element-helper";
import { modifier } from "ember-modifier";

import { viewport } from "./viewport.ts";

export type InViewportMode = "replace" | "contain";

/**
 * Configuration for the InViewport component
 */
export interface InViewportSignature {
  Element: HTMLElement;
  Args: {
    /**
     * The tag name for the placeholder element.
     * Can be any valid HTML tag name.
     * Default: 'div'
     */
    tagName?: string;

    /**
     * The mode determines how yielded content is rendered:
     * - 'replace': yielded content replaces the placeholder element
     * - 'contain': yielded content is rendered within the placeholder
     * Default: 'contain'
     */
    mode?: InViewportMode;
  };
  Blocks: {
    /**
     * Default block - rendered when the element is in the viewport
     */
    default: [];
  };
}

/**
 * A component that only renders its content when the element is near the viewport.
 *
 * This is useful for deferring the rendering of heavy components until they're
 * actually needed, improving performance for pages with many components.
 *
 * Example usage:
 * ```gjs
 * import { InViewport } from 'ember-primitives';
 *
 * <template>
 *   <InViewport>
 *     <ExpensiveComponent />
 *   </InViewport>
 * </template>
 * ```
 *
 * The component uses the Intersection Observer API to detect when the element
 * is near the viewport. Once detected, the observer is destroyed and the content
 * is rendered permanently.
 */
export class InViewport extends Component<InViewportSignature> {
  /**
   * Whether the element has been detected as in/near the viewport
   */
  @tracked hasIntersected = false;

  get #viewport() {
    return viewport(this);
  }

  setupObserver = modifier((element: Element) => {
    if (this.hasIntersected) {
      return;
    }

    this.#viewport.observe(element, this.handle);

    return () => this.#viewport.unobserve(element, this.handle);
  });

  handle = (entry: IntersectionObserverEntry) => {
    if (entry?.isIntersecting) {
      this.hasIntersected = true;

      this.#viewport.unobserve(entry.target, this.handle);
    }
  };

  get mode(): InViewportMode {
    assert(
      'InViewport mode must be either "replace" or "contain"',
      !this.args.mode || this.args.mode === "replace" || this.args.mode === "contain",
    );

    return this.args.mode ?? "contain";
  }

  get tagName(): string {
    return this.args.tagName ?? "div";
  }

  get hasReachedViewport(): boolean {
    return this.hasIntersected;
  }

  get isReplacing(): boolean {
    return this.mode === "replace";
  }

  <template>
    {{#let (element this.tagName) as |El|}}
      {{#if this.isReplacing}}
        {{#if this.hasReachedViewport}}
          {{yield}}
        {{else}}
          <El {{this.setupObserver}} ...attributes />
        {{/if}}
      {{else}}
        <El {{this.setupObserver}} ...attributes>
          {{#if this.hasReachedViewport}}
            {{yield}}
          {{/if}}
        </El>
      {{/if}}
    {{/let}}
  </template>
}


---

import { registerDestructor } from '@ember/destroyable';

import { createService } from '../service.ts';

/**
 * Creates or returns the ViewportObserverManager.
 *
 * Only one of these will exist per owner.
 *
 * Has only two methods:
 * - observe(element, callback: (intersectionObserverEntry) => void, options?)
 * - unobserve(element, callback: (intersectionObserverEntry) => void)
 *
 * Like with the underlying IntersectionObserver API (and all event listeners),
 * the callback passed to unobserve must be the same reference as the one
 * passed to observe.
 */
export function viewport(context: object) {
  return createService(context, ViewportObserverManager);
}

export interface ViewportOptions {
  /**
   * A margin around the root. Can have values similar to the CSS margin property.
   * The values can be percentages. This set of values serves to grow or shrink each
   * side of the root element's bounding box before computing intersections.
   * Defaults to all zeros.
   */
  rootMargin?: string;
  /**
   * Either a single number or an array of numbers which indicate at what percentage
   * of the target's visibility the observer's callback should be executed. If you only
   * want to detect when visibility passes the 50% mark, you can use a value of 0.5.
   * If you want the callback to run every time visibility passes another 25%, you would
   * specify the array [0, 0.25, 0.5, 0.75, 1]. The default is 0 (meaning as soon as
   * even one pixel is visible, the callback will be run).
   */
  threshold?: number | number[];
}

class ViewportObserverManager {
  #callbacks = new WeakMap<Element, Set<(entries: IntersectionObserverEntry) => unknown>>();

  #handleIntersection = (entries: IntersectionObserverEntry[]) => {
    for (const entry of entries) {
      const callbacks = this.#callbacks.get(entry.target);

      if (callbacks) {
        for (const callback of callbacks) {
          callback(entry);
        }
      }
    }
  };

  #observer = new IntersectionObserver(this.#handleIntersection, {
    /**
     * NOTE: clipping is unaffected by rootMargin if the intersection is with anything
     *       other than the specified "root".
     *       And since we don't specify the "root", this effectively means the window viewport.
     *       (hence the utility name: "viewport")
     */
  });

  constructor() {
    registerDestructor(this, () => {
      this.#observer?.disconnect();
    });
  }

  /**
   * Initiate the observing of the `element` or add an additional `callback`
   * if the `element` is already observed.
   *
   * @param {object} element
   * @param {function} callback The `callback` is called whenever the `element`
   *    intersects with the viewport. It is called with an `IntersectionObserverEntry`
   *    object for the particular `element`.
   */
  observe(element: Element, callback: (entry: IntersectionObserverEntry) => unknown) {
    const callbacks = this.#callbacks.get(element);

    if (callbacks) {
      callbacks.add(callback);
    } else {
      this.#callbacks.set(element, new Set([callback]));
      this.#observer.observe(element);
    }
  }

  /**
   * End the observing of the `element` or just remove the provided `callback`.
   *
   * It will unobserve the `element` if the `callback` is not provided
   * or there are no more callbacks left for this `element`.
   *
   * @param {Element | undefined | null} element
   * @param {function?} callback - The `callback` to remove from the listeners
   *   of the `element` intersection changes.
   */
  unobserve(
    element: Element | undefined | null,
    callback: (entry: IntersectionObserverEntry) => unknown
  ) {
    if (!element) {
      return;
    }

    const callbacks = this.#callbacks.get(element);

    if (!callbacks) {
      return;
    }

    callbacks.delete(callback);

    if (!callback || !callbacks.size) {
      this.#callbacks.delete(element);
      this.#observer.unobserve(element);
    }
  }
}


---

import type { TOC } from "@ember/component/template-only";

export const Div: TOC<{ Element: HTMLDivElement; Blocks: { default: [] } }> = <template>
  <div ...attributes>{{yield}}</div>
</template>;

export const Label: TOC<{
  Element: HTMLLabelElement;
  Args: { for: string };
  Blocks: { default: [] };
}> = <template>
  <label for={{@for}} ...attributes>{{yield}}</label>
</template>;


---

/**
 * If the user provides an onChange or similar function, use that,
 * otherwise fallback to the uncontrolled toggle
 */
export function toggleWithFallback(
  uncontrolledToggle: undefined | ((...args: any[]) => void) | (() => void),
  controlledToggle?: (...args: any[]) => void,
  ...args: unknown[]
) {
  if (controlledToggle) {
    return controlledToggle(...args);
  }

  uncontrolledToggle?.(...args);
}


---

import Component from "@glimmer/component";

import { getDataState } from "./item.gts";

import type { AccordionContentExternalSignature } from "./public.ts";

interface Signature extends AccordionContentExternalSignature {
  Args: {
    isExpanded: boolean;
    value: string;
    disabled?: boolean;
  };
}

export class AccordionContent extends Component<Signature> {
  <template>
    <div
      role="region"
      id={{@value}}
      data-state={{getDataState @isExpanded}}
      hidden={{this.isHidden}}
      data-disabled={{@disabled}}
      ...attributes
    >
      {{yield}}
    </div>
  </template>

  get isHidden() {
    return !this.args.isExpanded;
  }
}

export default AccordionContent;


---

import { hash } from "@ember/helper";

import { getDataState } from "./item.gts";
import Trigger from "./trigger.gts";

import type { AccordionHeaderExternalSignature } from "./public.ts";
import type { TOC } from "@ember/component/template-only";

interface Signature extends AccordionHeaderExternalSignature {
  Args: {
    value: string;
    isExpanded: boolean;
    disabled?: boolean;
    toggleItem: () => void;
  };
}

export const AccordionHeader: TOC<Signature> = <template>
  <div
    role="heading"
    aria-level="3"
    data-state={{getDataState @isExpanded}}
    data-disabled={{@disabled}}
    ...attributes
  >
    {{yield
      (hash
        Trigger=(component
          Trigger value=@value isExpanded=@isExpanded disabled=@disabled toggleItem=@toggleItem
        )
      )
    }}
  </div>
</template>;

export default AccordionHeader;


---

import Component from "@glimmer/component";
import { hash } from "@ember/helper";

import Content from "./content.gts";
import Header from "./header.gts";

import type { AccordionItemExternalSignature } from "./public.ts";

export function getDataState(isExpanded: boolean): string {
  return isExpanded ? "open" : "closed";
}

interface Signature extends AccordionItemExternalSignature {
  Args: AccordionItemExternalSignature["Args"] & {
    selectedValue?: string | string[];
    disabled?: boolean;
    toggleItem: (value: string) => void;
  };
}

export class AccordionItem extends Component<Signature> {
  <template>
    <div data-state={{getDataState this.isExpanded}} data-disabled={{@disabled}} ...attributes>
      {{yield
        (hash
          isExpanded=this.isExpanded
          Header=(component
            Header
            value=@value
            isExpanded=this.isExpanded
            disabled=@disabled
            toggleItem=this.toggleItem
          )
          Content=(component Content value=@value isExpanded=this.isExpanded disabled=@disabled)
        )
      }}
    </div>
  </template>

  get isExpanded(): boolean {
    if (Array.isArray(this.args.selectedValue)) {
      return this.args.selectedValue.includes(this.args.value);
    }

    return this.args.selectedValue === this.args.value;
  }

  toggleItem = (): void => {
    if (this.args.disabled) return;

    this.args.toggleItem(this.args.value);
  };
}

export default AccordionItem;


---

import type Content from './content.gts';
import type Header from './header.gts';
import type Trigger from './trigger.gts';
import type { WithBoundArgs } from '@glint/template';

export interface AccordionTriggerExternalSignature {
  Element: HTMLButtonElement;
  Blocks: {
    default: [];
  };
}

export interface AccordionContentExternalSignature {
  Element: HTMLDivElement;
  Blocks: {
    default: [];
  };
}

export interface AccordionHeaderExternalSignature {
  /**
   * Add aria-level according to the heading level where the accordion is used (default: 3).
   * See https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ for more information.
   */
  Element: HTMLDivElement;
  Blocks: {
    default: [
      {
        /**
         * The AccordionTrigger component.
         */
        Trigger: WithBoundArgs<typeof Trigger, 'value' | 'isExpanded' | 'disabled' | 'toggleItem'>;
      },
    ];
  };
}

export interface AccordionItemExternalSignature {
  Element: HTMLDivElement;
  Blocks: {
    default: [
      {
        /**
         * Whether the accordion item is expanded.
         */
        isExpanded: boolean;
        /**
         * The AccordionHeader component.
         */
        Header: WithBoundArgs<typeof Header, 'value' | 'isExpanded' | 'disabled' | 'toggleItem'>;
        /**
         * The AccordionContent component.
         */
        Content: WithBoundArgs<typeof Content, 'value' | 'isExpanded' | 'disabled'>;
      },
    ];
  };
  Args: {
    /**
     * The value of the accordion item.
     */
    value: string;
  };
}


---

import { on } from "@ember/modifier";

import { getDataState } from "./item.gts";

import type { AccordionTriggerExternalSignature } from "./public.ts";
import type { TOC } from "@ember/component/template-only";

interface Signature extends AccordionTriggerExternalSignature {
  Args: {
    isExpanded: boolean;
    value: string;
    disabled?: boolean;
    toggleItem: () => void;
  };
}

export const AccordionTrigger: TOC<Signature> = <template>
  <button
    type="button"
    aria-controls={{@value}}
    aria-expanded={{@isExpanded}}
    data-state={{getDataState @isExpanded}}
    data-disabled={{@disabled}}
    aria-disabled={{if @disabled "true" "false"}}
    {{on "click" @toggleItem}}
    ...attributes
  >
    {{yield}}
  </button>
</template>;

export default AccordionTrigger;


---

.ember-primitives__hero__wrapper {
  width: 100dvw;
  height: 100dvh;
  position: relative;
}


---

import "./hero.css";

import type { TOC } from "@ember/component/template-only";

export const Hero: TOC<{
  /**
   * The wrapper element of the whole layout.
   */
  Element: HTMLDivElement;
  Blocks: {
    default: [];
  };
}> = <template>
  <div class="ember-primitives__hero__wrapper" ...attributes>
    {{yield}}
  </div>
</template>;


---

.ember-primitives__sticky-footer__wrapper {
  height: 100%;
  overflow: auto;
}
.ember-primitives__sticky-footer__container {
  min-height: 100%;
  display: grid;
  grid-template-rows: 1fr auto;
}


---

import "./sticky-footer.css";

import type { TOC } from "@ember/component/template-only";

export const StickyFooter: TOC<{
  /**
   * The wrapper element of the whole layout.
   * Valid parents for this element must have either a set height,
   * or a set max-height.
   */
  Element: HTMLDivElement;
  Blocks: {
    /**
     * This is the scrollable content, contained within a `<div>` element for positioning.
     * If this component is used as the main layout on a page,
     * the `<main>` element would be appropriate within here.
     */
    content: [];
    /**
     * This is the footer content, contained within a `<div>` element for positioning.
     * A `<footer>` element would be appropriate within here.
     *
     * This element will be at the bottom of the page if the content does not overflow the containing element and this element will be at the bottom of the content if there is overflow.
     */
    footer: [];
  };
}> = <template>
  <div class="ember-primitives__sticky-footer__wrapper" ...attributes>
    <div class="ember-primitives__sticky-footer__container">
      <div class="ember-primitives__sticky-footer__content">
        {{yield to="content"}}
      </div>
      <div class="ember-primitives__sticky-footer__footer">
        {{yield to="footer"}}
      </div>
    </div>
  </div>
</template>;

export default StickyFooter;


---

import { assert } from "@ember/debug";
import { on } from "@ember/modifier";

import type { TOC } from "@ember/component/template-only";

const reset = (event: Event) => {
  assert("[BUG]: reset called without an event.target", event.target instanceof HTMLElement);

  const form = event.target.closest("form");

  assert(
    "Form is missing. Cannot use <Reset> without being contained within a <form>",
    form instanceof HTMLFormElement,
  );

  form.reset();
};

export const Submit: TOC<{
  Element: HTMLButtonElement;
  Blocks: { default: [] };
}> = <template>
  <button type="submit" ...attributes>Submit</button>
</template>;

export const Reset: TOC<{
  Element: HTMLButtonElement;
  Blocks: { default: [] };
}> = <template>
  <button type="button" {{on "click" reset}} ...attributes>{{yield}}</button>
</template>;


---

import Component from "@glimmer/component";
import { warn } from "@ember/debug";
import { isDestroyed, isDestroying } from "@ember/destroyable";
import { on } from "@ember/modifier";
import { buildWaiter } from "@ember/test-waiters";

import {
  autoAdvance,
  getCollectiveValue,
  handleNavigation,
  handlePaste,
  selectAll,
} from "./utils.ts";

import type { TOC } from "@ember/component/template-only";
import type { WithBoundArgs } from "@glint/template";

const DEFAULT_LENGTH = 6;

function labelFor(inputIndex: number, labelFn: undefined | ((index: number) => string)) {
  if (labelFn) {
    return labelFn(inputIndex);
  }

  return `Please enter OTP character ${inputIndex + 1}`;
}

const waiter = buildWaiter("ember-primitives:OTPInput:handleChange");

const Fields: TOC<{
  /**
   * Any attributes passed to this component will be applied to each input.
   */
  Element: HTMLInputElement;
  Args: {
    fields: unknown[];
    labelFn: (index: number) => string;
    handleChange: (event: Event) => void;
  };
}> = <template>
  {{#each @fields as |_field i|}}
    <label>
      <span class="ember-primitives__sr-only">{{labelFor i @labelFn}}</span>
      <input
        name="code{{i}}"
        type="text"
        inputmode="numeric"
        autocomplete="off"
        ...attributes
        {{on "click" selectAll}}
        {{on "paste" handlePaste}}
        {{on "input" autoAdvance}}
        {{on "input" @handleChange}}
        {{on "keydown" handleNavigation}}
      />
    </label>
  {{/each}}
</template>;

export class OTPInput extends Component<{
  /**
   * The collection of individual OTP inputs are contained by a fieldset.
   * Applying the `disabled` attribute to this fieldset will disable
   * all of the inputs, if that's desired.
   */
  Element: HTMLFieldSetElement;
  Args: {
    /**
     * How many characters the one-time-password field should be
     * Defaults to 6
     */
    length?: number;

    /**
     * To Customize the label of the input fields, you may pass a function.
     * By default, this is `Please enter OTP character ${index + 1}`.
     */
    labelFn?: (index: number) => string;

    /**
     * If passed, this function will be called when the <Input> changes.
     * All fields are considered one input.
     */
    onChange?: (
      data: {
        /**
         * The text from the collective `<Input>`
         *
         * `code` _may_ be shorter than `length`
         * if the user has not finished typing / pasting their code
         */
        code: string;
        /**
         * will be `true` if `code`'s length matches the passed `@length` or the default of 6
         */
        complete: boolean;
      },
      /**
       * The last input event received
       */
      event: Event,
    ) => void;
  };
  Blocks: {
    /**
     * Optionally, you may control how the Fields are rendered, with proceeding text,
     * additional attributes added, etc.
     *
     * This is how you can add custom validation to each input field.
     */
    default?: [fields: WithBoundArgs<typeof Fields, "fields" | "handleChange" | "labelFn">];
  };
}> {
  /**
   * This is debounced, because we bind to each input,
   * but only want to emit one change event if someone pastes
   * multiple characters
   */
  handleChange = (event: Event) => {
    if (!this.args.onChange) return;

    if (!this.#token) {
      this.#token = waiter.beginAsync();
    }

    if (this.#frame) {
      cancelAnimationFrame(this.#frame);
    }

    // We  use requestAnimationFrame to be friendly to rendering.
    // We don't know if onChange is going to want to cause paints
    // (it's also how we debounce, under the assumption that "paste" behavior
    //  would be fast enough to be quicker than individual frames
    //   (see logic in autoAdvance)
    //  )
    this.#frame = requestAnimationFrame(() => {
      waiter.endAsync(this.#token);

      if (isDestroyed(this) || isDestroying(this)) return;
      if (!this.args.onChange) return;

      const value = getCollectiveValue(event.target, this.length);

      if (value === undefined) {
        warn(`Value could not be determined for the OTP field. was it removed from the DOM?`, {
          id: "ember-primitives.OTPInput.missing-value",
        });

        return;
      }

      this.args.onChange({ code: value, complete: value.length === this.length }, event);
    });
  };

  #token: unknown;
  #frame: number | undefined;

  get length() {
    return this.args.length ?? DEFAULT_LENGTH;
  }

  get fields() {
    // We only need to iterate a number of times,
    // so we don't care about the actual value or
    // referential integrity here
    return new Array<undefined>(this.length);
  }

  <template>
    <fieldset ...attributes>
      {{#let
        (component Fields fields=this.fields handleChange=this.handleChange labelFn=@labelFn)
        as |CurriedFields|
      }}
        {{#if (has-block)}}
          {{yield CurriedFields}}
        {{else}}
          <CurriedFields />
        {{/if}}
      {{/let}}

      <style>
        .ember-primitives__sr-only {
          position: absolute;
          width: 1px;
          height: 1px;
          padding: 0;
          margin: -1px;
          overflow: hidden;
          clip: rect(0, 0, 0, 0);
          white-space: nowrap;
          border-width: 0;
        }
      </style>
    </fieldset>
  </template>
}


---

import { assert } from "@ember/debug";
import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { buildWaiter } from "@ember/test-waiters";

import { Reset, Submit } from "./buttons.gts";
import { OTPInput } from "./input.gts";

import type { TOC } from "@ember/component/template-only";
import type { WithBoundArgs } from "@glint/template";

const waiter = buildWaiter("ember-primitives:OTP:handleAutoSubmitAttempt");

const handleFormSubmit = (submit: (data: { code: string }) => void, event: SubmitEvent) => {
  event.preventDefault();

  assert(
    "[BUG]: handleFormSubmit was not attached to a form. Please open an issue.",
    event.currentTarget instanceof HTMLFormElement,
  );

  const formData = new FormData(event.currentTarget);

  let code = "";

  for (const [key, value] of formData.entries()) {
    if (key.startsWith("code")) {
      // eslint-disable-next-line @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-base-to-string
      code += value;
    }
  }

  submit({
    code,
  });
};

function handleChange(
  autoSubmit: boolean | undefined,
  data: { code: string; complete: boolean },
  event: Event,
) {
  if (!autoSubmit) return;
  if (!data.complete) return;

  assert(
    "[BUG]: event target is not a known element type",
    event.target instanceof HTMLElement || event.target instanceof SVGElement,
  );

  const form = event.target.closest("form");

  assert("[BUG]: Cannot handle event when <OTP> Inputs are not rendered within their <form>", form);

  const token = waiter.beginAsync();
  const finished = () => {
    waiter.endAsync(token);
    form.removeEventListener("submit", finished);
  };

  form.addEventListener("submit", finished);

  // NOTE: when calling .submit() the submit event handlers are not run
  form.requestSubmit();
}

export const OTP: TOC<{
  /**
   * The overall OTP Input is in its own form.
   * Modern UI/UX Patterns usually have this sort of field
   * as its own page, thus within its own form.
   *
   * By default, only the 'submit' event is bound, and is
   * what calls the `@onSubmit` argument.
   */
  Element: HTMLFormElement;
  Args: {
    /**
     * How many characters the one-time-password field should be
     * Defaults to 6
     */
    length?: number;

    /**
     * The on submit callback will give you the entered
     * one-time-password code.
     *
     * It will be called when the user manually clicks the 'submit'
     * button or when the full code is pasted and meats the validation
     * criteria.
     */
    onSubmit: (data: { code: string }) => void;

    /**
     * Whether or not to auto-submit after the code has been pasted
     * in to the collective "field".  Default is true
     */
    autoSubmit?: boolean;
  };
  Blocks: {
    default: [
      {
        /**
         * The collective input field that the OTP code will be typed/pasted in to
         */
        Input: WithBoundArgs<typeof OTPInput, "length" | "onChange">;
        /**
         * Button with `type="submit"` to submit the form
         */
        Submit: typeof Submit;
        /**
         * Pre-wired button to reset the form
         */
        Reset: typeof Reset;
      },
    ];
  };
}> = <template>
  <form {{on "submit" (fn handleFormSubmit @onSubmit)}} ...attributes>
    {{yield
      (hash
        Input=(component
          OTPInput length=@length onChange=(if @autoSubmit (fn handleChange @autoSubmit))
        )
        Submit=Submit
        Reset=Reset
      )
    }}
  </form>
</template>;


---

import { assert } from '@ember/debug';

function getInputs(current: HTMLInputElement) {
  const fieldset = current.closest('fieldset');

  assert('[BUG]: fieldset went missing', fieldset);

  return [...fieldset.querySelectorAll('input')];
}

function nextInput(current: HTMLInputElement) {
  const inputs = getInputs(current);
  const currentIndex = inputs.indexOf(current);

  return inputs[currentIndex + 1];
}

export function selectAll(event: Event) {
  const target = event.target;

  assert(`selectAll is only meant for use with input elements`, target instanceof HTMLInputElement);

  target.select();
}

export function handlePaste(event: Event) {
  const target = event.target;

  assert(
    `handlePaste is only meant for use with input elements`,
    target instanceof HTMLInputElement
  );

  const clipboardData = (event as ClipboardEvent).clipboardData;

  assert(
    `Could not get clipboardData while handling the paste event on OTP. Please report this issue on the ember-primitives repo with a reproduction. Thanks!`,
    clipboardData
  );

  // This is typically not good to prevent paste.
  // But because of the UX we're implementing,
  // we want to split the pasted value across
  // multiple text fields
  event.preventDefault();

  const value = clipboardData.getData('Text');
  const digits = value;
  let i = 0;
  let currElement: HTMLInputElement | null = target;

  while (currElement) {
    currElement.value = digits[i++] || '';

    const next = nextInput(currElement);

    if (next instanceof HTMLInputElement) {
      currElement = next;
    } else {
      break;
    }
  }

  // We want to select the first field again
  // so that if someone holds paste, or
  // pastes again, they get the same result.
  target.select();
}

export function handleNavigation(event: KeyboardEvent) {
  switch (event.key) {
    case 'Backspace':
      return handleBackspace(event);
    case 'ArrowLeft':
      return focusLeft(event);
    case 'ArrowRight':
      return focusRight(event);
  }
}

function focusLeft(event: Pick<Event, 'target'>) {
  const target = event.target;

  assert(`only allowed on input elements`, target instanceof HTMLInputElement);

  const input = previousInput(target);

  input?.focus();
  requestAnimationFrame(() => {
    input?.select();
  });
}

function focusRight(event: Pick<Event, 'target'>) {
  const target = event.target;

  assert(`only allowed on input elements`, target instanceof HTMLInputElement);

  const input = nextInput(target);

  input?.focus();
  requestAnimationFrame(() => {
    input?.select();
  });
}

const syntheticEvent = new InputEvent('input');

function handleBackspace(event: KeyboardEvent) {
  if (event.key !== 'Backspace') return;

  /**
   * We have to prevent default because we
   * - want to clear the whole field
   * - have the focus behavior keep up with the key-repeat
   *   speed of the user's computer
   */
  event.preventDefault();

  const target = event.target;

  if (target && 'value' in target) {
    if (target.value === '') {
      focusLeft({ target });
    } else {
      target.value = '';
    }
  }

  target?.dispatchEvent(syntheticEvent);
}

function previousInput(current: HTMLInputElement) {
  const inputs = getInputs(current);
  const currentIndex = inputs.indexOf(current);

  return inputs[currentIndex - 1];
}

export const autoAdvance = (event: Event) => {
  assert(
    '[BUG]: autoAdvance called on non-input element',
    event.target instanceof HTMLInputElement
  );

  const value = event.target.value;

  if (value.length === 0) return;

  if (value.length > 0) {
    if ('data' in event && event.data && typeof event.data === 'string') {
      event.target.value = event.data;
    }

    return focusRight(event);
  }
};

export function getCollectiveValue(elementTarget: EventTarget | null, length: number) {
  if (!elementTarget) return;

  assert(
    `[BUG]: somehow the element target is not HTMLElement`,
    elementTarget instanceof HTMLElement
  );

  let parent: null | HTMLElement | ShadowRoot;

  // TODO: should this logic be extracted?
  //       why is getting the target element within a shadow root hard?
  if (!(elementTarget instanceof HTMLInputElement)) {
    if (elementTarget.shadowRoot) {
      parent = elementTarget.shadowRoot;
    } else {
      parent = elementTarget.closest('fieldset');
    }
  } else {
    parent = elementTarget.closest('fieldset');
  }

  assert(`[BUG]: somehow the input fields were rendered without a parent element`, parent);

  const elements = parent.querySelectorAll('input');

  let value = '';

  assert(
    `found elements (${elements.length}) do not match length (${length}). Was the same OTP input rendered more than once?`,
    elements.length === length
  );

  for (const element of elements) {
    assert(
      '[BUG]: how did the queried elements become a non-input element?',
      element instanceof HTMLInputElement
    );
    value += element.value;
  }

  return value;
}


---

import type { ComponentLike } from '@glint/template';

/**
 * @public
 */
export interface ComponentIcons {
  /**
   * It's possible to completely manage the state of an individual Icon yourself
   * by passing a component that has ...attributes on its outer element and receives
   * a @isSelected argument which is true for selected and false for unselected.
   *
   * There is also argument passed which is the percent-amount of selection if you want fractional ratings, @selectedPercent
   */
  icon: ComponentLike<{
    Element: HTMLElement;
    Args: {
      /**
       * Is this item selected?
       */
      isSelected: boolean;
      /**
       * Which number of item is this item within the overall rating group.
       */
      value: number;
      /**
       * Should this be marked as readonly
       */
      readonly: boolean;
    };
  }>;
}

/**
 * @public
 */
export interface StringIcons {
  /**
   * The symbol to use for an unselected variant of the icon
   *
   * Defaults to "★";
   *  Can change color when selected.
   */
  icon?: string;
}


---

import { on } from "@ember/modifier";

import type { TOC } from "@ember/component/template-only";

export const RatingRange: TOC<{
  Element: HTMLInputElement;
  Args: {
    name: string;
    max: number;
    value: number;
    handleChange: (event: Event) => void;
  };
}> = <template>
  <input
    ...attributes
    name={{@name}}
    type="range"
    max={{@max}}
    value={{@value}}
    {{on "change" @handleChange}}
  />
</template>;


---

import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";

import { uniqueId } from "../../utils.ts";
import { RatingRange } from "./range.gts";
import { Stars } from "./stars.gts";
import { RatingState } from "./state.gts";

import type { ComponentIcons, StringIcons } from "./public-types.ts";
import type { WithBoundArgs } from "@glint/template";

export interface Signature {
  /*
   * The element all passed attributes / modifiers are applied to.
   *
   * This is a `<fieldset>`, becaues the rating elements are
   * powered by a group of radio buttons.
   */
  Element: HTMLFieldSetElement;
  Args: (ComponentIcons | StringIcons) & {
    /**
     * The number of stars/whichever-icon to show
     *
     * Defaults to 5
     */
    max?: number;

    /**
     * The current number of stars/whichever-icon to show as selected
     *
     * Defaults to 0
     */
    value?: number;

    /**
     * When generating the radio inputs, this changes what value of rating each radio
     * input will be incremented by.
     *
     * e.g.: Set to 0.5 for half-star ratings.
     *
     * Defaults to 1
     */
    step?: number;

    /**
     * Prevents click events on the icons and sets aria-readonly.
     *
     * Also sets data-readonly=true on the wrapping element
     */
    readonly?: boolean;

    /**
     * Toggles the ability to interact with the rating component.
     * When `true` (the default), the Rating component can be as a form input
     * to gather user feedback.
     *
     * When false, only the `@value` will be shown, and it cannot be changed.
     */
    interactive?: boolean;

    /**
     * Callback when the selected rating changes.
     * Can include half-ratings if the iconHalf argument is passed.
     */
    onChange?: (value: number) => void;
  };

  Blocks: {
    default: [
      rating: {
        /**
         * The maximum rating
         */
        max: number;
        /**
         * The maxium rating
         */
        total: number;
        /**
         * The current rating
         */
        value: number;
        /**
         * The name shared by the field group
         */
        name: string;
        /**
         * If the rating can be changed
         */
        isReadonly: boolean;
        /**
         * If the rating can be changed
         */
        isChangeable: boolean;
        /**
         * The stars / items radio group
         */
        Stars: WithBoundArgs<
          typeof Stars,
          "stars" | "icon" | "isReadonly" | "name" | "total" | "currentValue"
        >;
        /**
         * Input range for adjusting the rating via fractional means
         */
        Range: WithBoundArgs<typeof RatingRange, "max" | "value" | "name" | "handleChange">;
      },
    ];
    label: [
      state: {
        /**
         * The current rating
         */
        value: number;

        /**
         * The maximum rating
         */
        total: number;
      },
    ];
  };
}

export class Rating extends Component<Signature> {
  name = `rating-${uniqueId()}`;

  get icon() {
    return this.args.icon ?? "★";
  }

  get isInteractive() {
    return this.args.interactive ?? true;
  }

  get isChangeable() {
    const readonly = this.args.readonly ?? false;

    return !readonly && this.isInteractive;
  }

  get isReadonly() {
    return !this.isChangeable;
  }

  get needsDescription() {
    return !this.isInteractive;
  }

  <template>
    <RatingState
      @max={{@max}}
      @step={{@step}}
      @value={{@value}}
      @name={{this.name}}
      @readonly={{this.isReadonly}}
      @onChange={{@onChange}}
      as |r publicState|
    >
      <fieldset
        class="ember-primitives__rating"
        data-total={{r.total}}
        data-value={{r.value}}
        data-readonly={{this.isReadonly}}
        {{! We use event delegation, this isn't a primary interactive -- we're capturing events from inputs }}
        {{! template-lint-disable no-invalid-interactive }}
        {{on "click" r.handleClick}}
        ...attributes
      >
        {{#let
          (component
            Stars
            stars=r.stars
            icon=this.icon
            isReadonly=this.isReadonly
            name=this.name
            total=r.total
            currentValue=r.value
          )
          as |RatingStars|
        }}

          {{#if (has-block)}}
            {{yield
              (hash
                max=r.total
                total=r.total
                value=r.value
                name=this.name
                isReadonly=this.isReadonly
                isChangeable=this.isChangeable
                Stars=RatingStars
                Range=(component
                  RatingRange
                  step=r.step
                  max=r.total
                  value=r.value
                  name=this.name
                  handleChange=r.handleChange
                )
              )
            }}
          {{else}}
            {{#if this.needsDescription}}
              {{#if (has-block "label")}}
                {{yield publicState to="label"}}
              {{else}}
                <span visually-hidden class="ember-primitives__rating__label">Rated
                  {{r.value}}
                  out of
                  {{r.total}}</span>
              {{/if}}
            {{else}}
              {{#if (has-block "label")}}
                <legend>
                  {{yield publicState to="label"}}
                </legend>
              {{/if}}
            {{/if}}

            <RatingStars />
          {{/if}}
        {{/let}}

      </fieldset>
    </RatingState>
  </template>
}


---

import { uniqueId } from "../../utils.ts";
import { isString, lte } from "./utils.ts";

import type { ComponentIcons, StringIcons } from "./public-types.ts";
import type { TOC } from "@ember/component/template-only";

export const Stars: TOC<{
  Args: {
    // Configuration
    stars: number[];
    icon: StringIcons["icon"] | ComponentIcons["icon"];
    isReadonly: boolean;

    // HTML Boilerplate
    name: string;

    // State
    currentValue: number;
    total: number;
  };
}> = <template>
  <div class="ember-primitives__rating__items">
    {{#each @stars as |star|}}
      {{#let (uniqueId) as |id|}}
        <span
          class="ember-primitives__rating__item"
          data-number={{star}}
          data-selected={{lte star @currentValue}}
          data-readonly={{@isReadonly}}
        >
          <label for="input-{{id}}">
            <span visually-hidden>{{star}} star</span>
            {{#if @icon}}
              <span aria-hidden="true">
                {{#if (isString @icon)}}
                  {{@icon}}
                {{else}}
                  <@icon
                    @value={{star}}
                    @isSelected={{lte star @currentValue}}
                    @readonly={{@isReadonly}}
                  />
                {{/if}}
              </span>
            {{/if}}
          </label>

          <input
            id="input-{{id}}"
            type="radio"
            name={{@name}}
            value={{star}}
            readonly={{@isReadonly}}
            checked={{Object.is star @currentValue}}
          />
        </span>
      {{/let}}
    {{/each}}
  </div>
</template>;


---

import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { assert } from "@ember/debug";
import { hash } from "@ember/helper";

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
import { localCopy } from "tracked-toolbox";

export class RatingState extends Component<{
  Args: {
    max: number | undefined;
    value: number | undefined;
    step: number | undefined;
    readonly: boolean | undefined;
    name: string;
    onChange?: (value: number) => void;
  };
  Blocks: {
    default: [
      internalApi: {
        stars: number[];
        step: number;
        value: number;
        total: number;
        handleClick: (event: Event) => void;
        handleChange: (event: Event) => void;
        setRating: (num: number) => void;
      },
      publicApi: {
        value: number;
        total: number;
      },
    ];
  };
}> {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
  @localCopy("args.value") declare _value: number;

  get value() {
    return this._value ?? 0;
  }

  get step() {
    return this.args.step ?? 1;
  }

  get max() {
    return this.args.max ?? 5;
  }

  @cached
  get stars() {
    const result = [];

    // 0 is "none selected"
    let current = 0;

    current += this.step;

    while (current <= this.max) {
      result.push(current);
      current += this.step;
    }

    return result;
  }

  setRating = (value: number) => {
    if (this.args.readonly) {
      return;
    }

    if (value === this._value) {
      this._value = 0;
    } else {
      this._value = value;
    }

    this.args.onChange?.(value);
  };

  setFromString = (value: unknown) => {
    assert("[BUG]: value from input must be a string.", typeof value === "string");

    const num = parseFloat(value);

    if (isNaN(num)) {
      // something went wrong.
      // Since we're using event delegation,
      // this could be from an unrelated input
      return;
    }

    this.setRating(num);
  };

  /**
   * Click events are captured by
   * - radio changes (mouse and keyboard)
   *   - but only range clicks
   */
  handleClick = (event: Event) => {
    // Since we're doing event delegation on a click, we want to make sure
    // we don't do anything on other elements
    const isValid =
      event.target instanceof HTMLInputElement &&
      event.target.name === this.args.name &&
      event.target.type === "radio";

    if (!isValid) return;

    const selected = event.target?.value;

    this.setFromString(selected);
  };

  /**
   * Only attached to a range element, if present.
   * Range elements don't fire click events on keyboard usage, like radios do
   */
  handleChange = (event: Event) => {
    const isValid = event.target !== null && "value" in event.target;

    if (!isValid) return;

    this.setFromString(event.target.value);
  };

  <template>
    {{yield
      (hash
        stars=this.stars
        total=this.stars.length
        handleClick=this.handleClick
        handleChange=this.handleChange
        setRating=this.setRating
        value=this.value
        step=this.step
      )
      (hash total=this.stars.length value=this.value)
    }}
  </template>
}


---

export function isString(x: unknown) {
  return typeof x === 'string';
}

export function lte(a: number, b: number) {
  return a <= b;
}


---

export type Orientation = 'horizontal' | 'vertical';

const GROUP_SELECTOR = '.ember-primitives__resizable';
const PANEL_CLASS = 'ember-primitives__resizable__panel';
const HANDLE_CLASS = 'ember-primitives__resizable__handle';
const MEMBER_SELECTOR = `.${PANEL_CLASS}, .${HANDLE_CLASS}`;

const DEFAULT_MIN = 0;
const DEFAULT_MAX = 100;

/**
 * How far (in %) one keyboard arrow press moves a handle.
 * Holding Shift moves in coarser increments.
 */
const KEYBOARD_STEP = 1;
const KEYBOARD_STEP_COARSE = 10;

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

function numberAttribute(element: Element, name: string): number | undefined {
  const raw = element.getAttribute(name);

  if (raw === null) return undefined;

  const value = parseFloat(raw);

  return Number.isFinite(value) ? value : undefined;
}

/**
 * Panels declare their constraints in the DOM (via data attributes),
 * so the group can discover everything it needs with queries --
 * no registration required.
 */
function minSizeOf(panel: Element): number {
  return numberAttribute(panel, 'data-min-size') ?? DEFAULT_MIN;
}

function maxSizeOf(panel: Element): number {
  return numberAttribute(panel, 'data-max-size') ?? DEFAULT_MAX;
}

function requestedSizeOf(panel: Element): number | undefined {
  return numberAttribute(panel, 'data-size');
}

function isCollapsible(panel: Element): boolean {
  return panel.hasAttribute('data-collapsible');
}

function sameMembers(a: HTMLElement[], b: HTMLElement[]): boolean {
  return a.length === b.length && a.every((element, index) => element === b[index]);
}

function precedes(a: Element, b: Element): boolean {
  return Boolean(b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_PRECEDING);
}

/**
 * The `data-collapsed` attribute is the source of truth for collapse
 * state (it is also the styling hook consumers use).
 */
function isCollapsed(panel: HTMLElement): boolean {
  return isCollapsible(panel) && panel.hasAttribute('data-collapsed');
}

function setCollapsed(panel: HTMLElement, collapsed: boolean): void {
  if (collapsed === panel.hasAttribute('data-collapsed')) return;

  if (collapsed) {
    panel.setAttribute('data-collapsed', '');
  } else {
    panel.removeAttribute('data-collapsed');
  }
}

/**
 * The panels immediately before and after the given handle element,
 * in document order.
 */
function neighborsOf(
  handleElement: HTMLElement,
  panels: HTMLElement[]
): [HTMLElement | null, HTMLElement | null] {
  let prev: HTMLElement | null = null;
  let next: HTMLElement | null = null;

  for (const panel of panels) {
    const position = handleElement.compareDocumentPosition(panel);

    if (position & Node.DOCUMENT_POSITION_PRECEDING) {
      prev = panel;
    } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
      next = panel;

      break;
    }
  }

  return [prev, next];
}

/**
 * Sizes are floats that get re-derived (measurement, normalization),
 * so "unchanged" means within a small tolerance.
 */
function isSameSize(existing: number | undefined, size: number): boolean {
  return existing !== undefined && Math.abs(existing - size) < 0.0001;
}

/**
 * Pixel measurements round to device pixels, so re-measuring a layout
 * yields values that differ from the stored ones by sub-pixel noise.
 * Only differences beyond this (in %) count as real drift worth
 * adopting -- re-encoding identical pixels as slightly different
 * percentages would dirty every panel for no visual change.
 */
const MEASUREMENT_TOLERANCE = 0.25;

/**
 * Skips the write when the attribute already has the desired value,
 * so unchanged elements are left untouched.
 */
function setAttribute(element: Element, name: string, value: string): void {
  if (element.getAttribute(name) !== value) {
    element.setAttribute(name, value);
  }
}

interface Members {
  panels: HTMLElement[];
  handles: HTMLElement[];
}

interface DragState {
  prev: HTMLElement;
  next: HTMLElement;
  startPrevSize: number;
  startNextSize: number;
  startCoordinate: number;
  /**
   * Total px occupied by the panels (excluding handles) at drag start,
   * used to convert px deltas to % deltas.
   */
  totalPx: number;
  members: Members;
  move: (event: PointerEvent) => void;
  end: (event: PointerEvent) => void;
}

interface GroupOptions {
  orientation: () => Orientation | undefined;
  onLayoutChange: () => ((sizes: number[]) => void) | undefined;
}

export class GroupState {
  element: HTMLElement | null = null;

  #options: GroupOptions;
  #drag: DragState | null = null;

  /**
   * Current size (%) per panel element.
   * The DOM is the source of truth for membership; this only remembers
   * sizes across relayouts.
   */
  #sizes = new Map<HTMLElement, number>();

  /**
   * The size (%) a collapsible panel had before it was collapsed,
   * so that expanding restores it.
   */
  #previousSizes = new WeakMap<HTMLElement, number>();

  /**
   * Membership as of the last layout, so mutation batches that don't
   * change membership (e.g. content changes inside a panel, or churn
   * within a nested group) can be ignored.
   */
  #knownPanels: HTMLElement[] = [];
  #knownHandles: HTMLElement[] = [];

  /**
   * The percentage that a flex-grow of 1 represents in this group.
   *
   * Panels without an inline style render at the CSS default
   * (`flex: 1 1 0px`), so an all-equal group needs no styles at all --
   * mounting one writes nothing. The unit is fixed the first time a
   * panel actually diverges, and from then on grow values are written
   * relative to it, so panels whose share doesn't change keep their
   * (possibly absent) style untouched.
   */
  #unit: number | null = null;

  constructor(options: GroupOptions) {
    this.#options = options;
  }

  get orientation(): Orientation {
    return this.#options.orientation() ?? 'horizontal';
  }

  get #isHorizontal(): boolean {
    return this.orientation === 'horizontal';
  }

  /**
   * This group's panels, in document order.
   * Panels of nested groups belong to their own group, not this one.
   */
  get panels(): HTMLElement[] {
    return this.#members().panels;
  }

  get handles(): HTMLElement[] {
    return this.#members().handles;
  }

  #members(): Members {
    const panels: HTMLElement[] = [];
    const handles: HTMLElement[] = [];
    const element = this.element;

    if (!element) return { panels, handles };

    for (const member of element.querySelectorAll<HTMLElement>(MEMBER_SELECTOR)) {
      if (member.closest(GROUP_SELECTOR) !== element) continue;

      (member.classList.contains(PANEL_CLASS) ? panels : handles).push(member);
    }

    return { panels, handles };
  }

  get sizes(): number[] {
    return this.panels.map((panel) => this.#sizes.get(panel) ?? 0);
  }

  /**
   * One MutationObserver for every group on the page. Each record is
   * routed to the group that owns it (the nearest group ancestor), so
   * churn inside a nested group never even pings its ancestors.
   */
  static #observed = new Map<HTMLElement, GroupState>();
  static #observer: MutationObserver | null = null;

  static #observerOptions: MutationObserverInit = {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['data-orientation'],
    // to distinguish real changes from same-value writes (see below)
    attributeOldValue: true,
  };

  static #handleMutations(mutations: MutationRecord[]): void {
    /**
     * true = the group's own data-orientation changed,
     * false = something in its subtree changed (membership check needed)
     */
    const affected = new Map<GroupState, boolean>();

    for (const mutation of mutations) {
      const target = mutation.target;

      if (!(target instanceof Element)) continue;

      if (mutation.type === 'attributes') {
        const group = GroupState.#observed.get(target as HTMLElement);

        /**
         * setAttribute queues a record even when the value is
         * unchanged (renderers do rewrite attributes with the same
         * value); only actual changes warrant a relayout.
         */
        const changed =
          mutation.attributeName &&
          mutation.oldValue !== target.getAttribute(mutation.attributeName);

        if (group && changed) affected.set(group, true);
        continue;
      }

      const groupElement = target.closest<HTMLElement>(GROUP_SELECTOR);
      const group = groupElement && GroupState.#observed.get(groupElement);

      if (group && !affected.has(group)) affected.set(group, false);
    }

    for (const [group, orientationChanged] of affected) {
      const members = group.#members();

      if (orientationChanged || group.#membershipChanged(members)) group.#layout(members);
    }
  }

  static #observe(element: HTMLElement, group: GroupState): void {
    GroupState.#observed.set(element, group);
    GroupState.#observer ??= new MutationObserver((mutations) =>
      GroupState.#handleMutations(mutations)
    );
    GroupState.#observer.observe(element, GroupState.#observerOptions);
  }

  static #unobserve(element: HTMLElement): void {
    GroupState.#observed.delete(element);

    const observer = GroupState.#observer;

    if (!observer) return;

    /**
     * MutationObserver has no per-target unobserve; disconnect and
     * re-observe the remaining groups (rare -- teardown only).
     */
    observer.disconnect();

    if (GroupState.#observed.size === 0) {
      GroupState.#observer = null;

      return;
    }

    for (const remaining of GroupState.#observed.keys()) {
      observer.observe(remaining, GroupState.#observerOptions);
    }
  }

  /**
   * Called (via modifier) when the group element is inserted.
   * Watches for panels being added/removed (and the orientation
   * changing) and performs the initial layout.
   */
  attach = (element: HTMLElement): (() => void) => {
    this.element = element;

    GroupState.#observe(element, this);

    this.#layout();

    return () => {
      GroupState.#unobserve(element);
      this.element = null;
    };
  };

  #membershipChanged(members: Members): boolean {
    return (
      !sameMembers(members.panels, this.#knownPanels) ||
      !sameMembers(members.handles, this.#knownHandles)
    );
  }

  /**
   * Panels that already have a size (or request one via `data-size`)
   * keep it; new panels take a share of the remaining space; everything
   * is normalized to 100.
   */
  #layout(members: Members = this.#members()): void {
    const { panels, handles } = members;

    if (this.#drag) this.#drag.members = members;

    this.#knownPanels = panels;
    this.#knownHandles = handles;

    if (panels.length === 0) return;

    let changed = false;

    // forget sizes of panels that left the DOM
    const current = new Set(panels);

    for (const known of this.#sizes.keys()) {
      if (!current.has(known)) {
        this.#sizes.delete(known);
        changed = true;
      }
    }

    // scratch space for the math below; #sizes itself is only
    // touched at the end, and only where values actually changed
    const computed = new Map<HTMLElement, number>();
    const unspecified: HTMLElement[] = [];
    let specifiedTotal = 0;

    for (const panel of panels) {
      const preferred = this.#sizes.get(panel) ?? requestedSizeOf(panel);

      if (preferred === undefined) {
        unspecified.push(panel);
        continue;
      }

      const size = isCollapsed(panel)
        ? preferred
        : clamp(preferred, minSizeOf(panel), maxSizeOf(panel));

      computed.set(panel, size);
      specifiedTotal += size;
    }

    if (unspecified.length > 0) {
      /**
       * Panels without a size share the remaining space.
       * When there is none left (e.g. a panel was added to an
       * already-full group), each takes an equal 1/n share and the
       * existing panels scale down to make room -- like a new window
       * opening in a tiling window manager.
       */
      const remaining = 100 - specifiedTotal;
      let share = remaining / unspecified.length;

      if (remaining < 1) {
        share = 100 / panels.length;

        if (specifiedTotal > 0) {
          const scale = Math.max(100 - share * unspecified.length, 0) / specifiedTotal;

          for (const [panel, size] of computed) {
            computed.set(panel, size * scale);
          }
        }
      }

      for (const panel of unspecified) {
        computed.set(panel, clamp(share, minSizeOf(panel), maxSizeOf(panel)));
      }
    }

    // normalize to 100
    let total = 0;

    for (const size of computed.values()) total += size;

    if (total > 0 && Math.abs(total - 100) > 0.01) {
      for (const [panel, size] of computed) {
        computed.set(panel, (size / total) * 100);
      }
    }

    /**
     * Commit minimally: keep the #sizes map, update only entries whose
     * value actually changed (with a small tolerance, so float dust
     * from re-normalizing doesn't count as a change).
     */
    const changedPanels: HTMLElement[] = [];

    for (const [panel, size] of computed) {
      if (!isSameSize(this.#sizes.get(panel), size)) {
        this.#sizes.set(panel, size);
        changedPanels.push(panel);
        changed = true;
      }
    }

    this.#apply(changedPanels, members);

    if (changed) this.#notify(panels);
  }

  /**
   * Writes the layout back to the DOM: flex sizing for exactly the
   * candidate panels whose rendered share would actually change, plus
   * the handles' ARIA attributes (which are guarded per-attribute).
   * (`data-collapsed` is managed at the explicit collapse/expand
   * points, not derived from sizes -- rendered pixel sizes include
   * borders/padding, so a collapsed panel rarely measures exactly 0.)
   */
  #apply(candidates: HTMLElement[], members: Members): void {
    /**
     * With no unit fixed yet, nothing has ever been written, so every
     * panel renders at the CSS default -- an equal 1/n share.
     */
    const unit = this.#unit ?? 100 / members.panels.length;

    for (const panel of candidates) {
      const size = this.#sizes.get(panel);

      if (size === undefined) continue;

      const inlineGrow = panel.style.flexGrow;
      const impliedPercent = (inlineGrow === '' ? 1 : parseFloat(inlineGrow)) * unit;

      if (isSameSize(impliedPercent, size)) continue;

      this.#unit ??= unit;
      panel.style.flex = `${size / unit} 1 0px`;
    }

    this.#syncHandles(members);
  }

  /**
   * Per the window-splitter pattern, each handle describes the panel
   * immediately before it. (A splitter between two side-by-side panes
   * is oriented *vertically*, and vice-versa.)
   */
  #syncHandles(members: Members): void {
    const { panels, handles } = members;
    const ariaOrientation = this.#isHorizontal ? 'vertical' : 'horizontal';

    let index = 0;

    for (const handle of handles) {
      let following = panels[index];

      while (following && precedes(following, handle)) {
        index++;
        following = panels[index];
      }

      const prev = panels[index - 1];

      setAttribute(handle, 'aria-orientation', ariaOrientation);

      if (!prev) continue;

      // Panels render their own (component-owned, incrementing) id
      if (prev.id) setAttribute(handle, 'aria-controls', prev.id);

      setAttribute(handle, 'aria-valuemin', `${minSizeOf(prev)}`);
      setAttribute(handle, 'aria-valuemax', `${maxSizeOf(prev)}`);
      setAttribute(handle, 'aria-valuenow', `${Math.round(this.#sizes.get(prev) ?? 0)}`);
    }
  }

  #notify(panels: HTMLElement[]): void {
    this.#options.onLayoutChange()?.(panels.map((panel) => this.#sizes.get(panel) ?? 0));
  }

  /**
   * Re-derive percentage sizes from actual rendered pixels.
   * Corrects any drift (e.g. from CSS min-sizes) before an interaction.
   */
  #syncSizesFromDOM(panels: HTMLElement[], measured?: number[]): void {
    // collapsed panels are 0 even though their borders/padding measure larger
    const px = panels.map((panel, index) =>
      isCollapsed(panel) ? 0 : (measured?.[index] ?? this.#pixelSizeOf(panel))
    );
    const total = px.reduce((sum, value) => sum + value, 0);

    if (total <= 0) return;

    panels.forEach((panel, index) => {
      const size = ((px[index] ?? 0) / total) * 100;
      const existing = this.#sizes.get(panel);

      if (existing === undefined || Math.abs(existing - size) > MEASUREMENT_TOLERANCE) {
        this.#sizes.set(panel, size);
      }
    });
  }

  #pixelSizeOf(panel: HTMLElement): number {
    const box = panel.getBoundingClientRect();

    return this.#isHorizontal ? box.width : box.height;
  }

  /**
   * Moves the boundary between the two panels by `requestedDelta` (%),
   * respecting both panels' min/max constraints.
   */
  #applyDelta(
    prev: HTMLElement,
    next: HTMLElement,
    basePrevSize: number,
    baseNextSize: number,
    requestedDelta: number,
    members: Members
  ): void {
    const total = basePrevSize + baseNextSize;
    const prevMin = minSizeOf(prev);
    const prevMax = maxSizeOf(prev);
    const prevCollapsible = isCollapsible(prev);

    let target = basePrevSize + requestedDelta;

    if (prevCollapsible && target < prevMin) {
      /**
       * Collapsible panels snap: below half the minimum they close
       * entirely; between half and the minimum they hold at the minimum.
       * (This also keeps a collapsed panel closed when its handle is
       * dragged further in the closing direction.)
       */
      target = target < prevMin / 2 ? 0 : prevMin;
    } else {
      target = clamp(target, prevMin, prevMax);
    }

    /**
     * The neighbor absorbs whatever the target panel gives or takes,
     * so its constraints bound the target too.
     */
    const nextMin = total - maxSizeOf(next);
    const nextMax = total - minSizeOf(next);

    if (nextMin > nextMax) return;

    target = clamp(target, nextMin, nextMax);

    // both panels' constraints cannot be satisfied at once
    if (!prevCollapsible && (target < prevMin || target > prevMax)) return;

    /**
     * Nothing to do when the clamped result matches the current sizes
     * (e.g. every pointermove past a min/max limit).
     */
    if (
      isSameSize(this.#sizes.get(prev), target) &&
      isSameSize(this.#sizes.get(next), total - target)
    ) {
      return;
    }

    if (prevCollapsible) {
      if (target === 0 && !isCollapsed(prev)) {
        this.#previousSizes.set(prev, basePrevSize);
      }

      setCollapsed(prev, target === 0);
    }

    this.#sizes.set(prev, target);
    this.#sizes.set(next, total - target);

    this.#apply([prev, next], members);
    this.#notify(members.panels);
  }

  startDrag(handleElement: HTMLElement, event: PointerEvent): void {
    if (event.button !== 0) return;
    if (this.#drag) return;

    const members = this.#members();
    const [prev, next] = neighborsOf(handleElement, members.panels);

    if (!prev || !next) return;

    const measured = members.panels.map((panel) => this.#pixelSizeOf(panel));

    this.#syncSizesFromDOM(members.panels, measured);

    const move = (moveEvent: PointerEvent) => this.#dragMove(moveEvent);
    const end = (endEvent: PointerEvent) => this.#endDrag(handleElement, endEvent);

    this.#drag = {
      prev,
      next,
      startPrevSize: this.#sizes.get(prev) ?? 0,
      startNextSize: this.#sizes.get(next) ?? 0,
      startCoordinate: this.#isHorizontal ? event.clientX : event.clientY,
      totalPx: measured.reduce((sum, value) => sum + value, 0),
      members,
      move,
      end,
    };

    try {
      /**
       * Retargets all subsequent pointer events to the handle,
       * even when the pointer is over an iframe.
       */
      handleElement.setPointerCapture(event.pointerId);
    } catch {
      // synthetic events (tests) may not have an active pointer
    }

    handleElement.addEventListener('pointermove', move);
    handleElement.addEventListener('pointerup', end);
    handleElement.addEventListener('pointercancel', end);
    handleElement.setAttribute('data-resizing', '');

    document.body.style.cursor = this.#isHorizontal ? 'col-resize' : 'row-resize';
    document.body.style.userSelect = 'none';
  }

  #dragMove(event: PointerEvent): void {
    const drag = this.#drag;

    if (!drag) return;
    if (drag.totalPx <= 0) return;

    const coordinate = this.#isHorizontal ? event.clientX : event.clientY;
    const deltaPercent = ((coordinate - drag.startCoordinate) / drag.totalPx) * 100;

    this.#applyDelta(
      drag.prev,
      drag.next,
      drag.startPrevSize,
      drag.startNextSize,
      deltaPercent,
      drag.members
    );
  }

  #endDrag(handleElement: HTMLElement, event: PointerEvent): void {
    const drag = this.#drag;

    if (!drag) return;

    this.#drag = null;

    try {
      handleElement.releasePointerCapture(event.pointerId);
    } catch {
      // may not have been captured (tests)
    }

    handleElement.removeEventListener('pointermove', drag.move);
    handleElement.removeEventListener('pointerup', drag.end);
    handleElement.removeEventListener('pointercancel', drag.end);
    handleElement.removeAttribute('data-resizing');

    document.body.style.cursor = '';
    document.body.style.userSelect = '';
  }

  /**
   * Keyboard support for the WAI-ARIA window-splitter pattern.
   */
  handleKeyDown(handleElement: HTMLElement, event: KeyboardEvent): void {
    const members = this.#members();
    const [prev, next] = neighborsOf(handleElement, members.panels);

    if (!prev || !next) return;

    if (event.key === 'Enter') {
      this.#toggleCollapse(prev, next, members);

      return;
    }

    const step = event.shiftKey ? KEYBOARD_STEP_COARSE : KEYBOARD_STEP;
    const isHorizontal = this.#isHorizontal;

    let toDelta: ((prevSize: number) => number) | null = null;

    switch (event.key) {
      case 'ArrowLeft':
        if (isHorizontal) toDelta = () => -step;

        break;
      case 'ArrowRight':
        if (isHorizontal) toDelta = () => step;

        break;
      case 'ArrowUp':
        if (!isHorizontal) toDelta = () => -step;

        break;
      case 'ArrowDown':
        if (!isHorizontal) toDelta = () => step;

        break;
      case 'Home':
        toDelta = (prevSize) => minSizeOf(prev) - prevSize;

        break;
      case 'End':
        toDelta = (prevSize) => maxSizeOf(prev) - prevSize;

        break;
    }

    if (!toDelta) return;

    event.preventDefault();

    this.#syncSizesFromDOM(members.panels);

    const prevSize = this.#sizes.get(prev) ?? 0;

    this.#applyDelta(prev, next, prevSize, this.#sizes.get(next) ?? 0, toDelta(prevSize), members);
  }

  /**
   * Collapses (or restores) the panel before the handle,
   * giving the space to (or taking it from) the panel after it.
   *
   * Only does anything when the preceding panel is `@collapsible`.
   */
  #toggleCollapse(prev: HTMLElement, next: HTMLElement, members: Members): void {
    if (!isCollapsible(prev)) return;

    this.#syncSizesFromDOM(members.panels);

    const prevSize = this.#sizes.get(prev) ?? 0;
    const nextSize = this.#sizes.get(next) ?? 0;

    if (isCollapsed(prev)) {
      const preferred =
        this.#previousSizes.get(prev) ?? requestedSizeOf(prev) ?? Math.max(minSizeOf(prev), 10);
      const available = nextSize - minSizeOf(next);
      const restored = Math.min(preferred, available);

      if (restored <= 0) return;

      setCollapsed(prev, false);
      this.#sizes.set(prev, restored);
      this.#sizes.set(next, nextSize - restored);
    } else {
      this.#previousSizes.set(prev, prevSize);
      setCollapsed(prev, true);
      this.#sizes.set(prev, 0);
      this.#sizes.set(next, nextSize + prevSize);
    }

    this.#apply([prev, next], members);
    this.#notify(members.panels);
  }
}


---

import { tracked } from '@glimmer/tracking';
import { htmlSafe } from '@ember/template';

/**
 * `style` attribute values need to be SafeStrings to avoid Ember's
 * style-binding warning.
 */
export type StyleString = ReturnType<typeof htmlSafe>;

export interface SliderStoreArgs {
  value?: number | number[];
  min?: number;
  max?: number;
  step?: number | number[];
  orientation?: 'horizontal' | 'vertical';
  disabled?: boolean;
  onValueChange?: (value: number | number[]) => void;
  onValueCommit?: (value: number | number[]) => void;
}

export interface SliderThumb {
  index: number;
  value: number;
  /**
   * The value to pass to `<input type="range">`.
   *
   * When using an array `step`, this is the internal index (0..n-1).
   * Otherwise it's the same as `value`.
   */
  inputValue: number;
  percent: number;
  active: boolean;
  /**
   * Inline style positioning this thumb along the track
   * (`left: x%` or `bottom: x%`, depending on orientation).
   */
  positionStyle: StyleString;
}

const DEFAULT_MIN = 0;
const DEFAULT_MAX = 100;
const DEFAULT_STEP = 1;

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

function roundToStep(value: number, step: number): number {
  if (!Number.isFinite(step) || step <= 0) return value;

  return Math.round(value / step) * step;
}

function getPercentage(value: number, min: number, max: number): number {
  const range = max - min;

  if (!Number.isFinite(range) || range === 0) return 0;

  return ((value - min) / range) * 100;
}

function normalizeTickValues(values: number[]): number[] {
  const uniques = new Set<number>();

  for (const value of values) {
    if (Number.isFinite(value)) uniques.add(value);
  }

  return Array.from(uniques).sort((a, b) => a - b);
}

function findNearestIndex(values: number[], target: number): number {
  if (values.length === 0) return 0;

  const first = values[0];

  if (first === undefined) return 0;

  let nearestIndex = 0;
  let nearestDistance = Math.abs(first - target);

  for (let index = 1; index < values.length; index++) {
    const candidate = values[index];

    if (candidate === undefined) continue;

    const distance = Math.abs(candidate - target);

    if (distance < nearestDistance) {
      nearestIndex = index;
      nearestDistance = distance;
    }
  }

  return nearestIndex;
}

class ThumbState implements SliderThumb {
  #slider: SliderStore;

  constructor(
    slider: SliderStore,
    public index: number
  ) {
    this.#slider = slider;
  }

  get value(): number {
    return this.#slider.values[this.index] ?? this.#slider.min;
  }

  get inputValue(): number {
    const ticks = this.#slider.tickValues;

    if (!ticks) return this.value;

    return clamp(findNearestIndex(ticks, this.value), 0, Math.max(0, ticks.length - 1));
  }

  get percent(): number {
    return this.#slider.thumbPercents[this.index] ?? 0;
  }

  get active(): boolean {
    return this.#slider.activeThumbIndex === this.index;
  }

  get positionStyle(): StyleString {
    return this.#slider.thumbPositionStyle(this.percent);
  }
}

export class SliderStore {
  #thumbStates: ThumbState[] = [];
  #getArgs: () => SliderStoreArgs;

  @tracked activeThumbIndex: number | null = null;

  constructor(getArgs: () => SliderStoreArgs) {
    this.#getArgs = getArgs;

    const args = this.#getArgs();
    const initialCount = Array.isArray(args.value) ? Math.max(1, args.value.length) : 1;

    this.#thumbStates = Array.from(
      { length: initialCount },
      (_, index) => new ThumbState(this, index)
    );
  }

  get #args(): SliderStoreArgs {
    return this.#getArgs();
  }

  get min(): number {
    return this.#args.min ?? DEFAULT_MIN;
  }

  get max(): number {
    return this.#args.max ?? DEFAULT_MAX;
  }

  get step(): number {
    return typeof this.#args.step === 'number' ? this.#args.step : DEFAULT_STEP;
  }

  get tickValues(): number[] | null {
    const fromStep = Array.isArray(this.#args.step) ? this.#args.step : undefined;
    const raw = fromStep;

    if (!raw) return null;

    const normalized = normalizeTickValues(raw);

    return normalized.length === 0 ? null : normalized;
  }

  get orientation(): 'horizontal' | 'vertical' {
    return this.#args.orientation ?? 'horizontal';
  }

  get disabled(): boolean {
    return this.#args.disabled ?? false;
  }

  get internalMin(): number {
    const ticks = this.tickValues;

    if (ticks) return 0;

    return this.min;
  }

  get internalMax(): number {
    const ticks = this.tickValues;

    if (ticks) return Math.max(0, ticks.length - 1);

    return this.max;
  }

  get internalStep(): number {
    const ticks = this.tickValues;

    if (ticks) return 1;

    return this.step;
  }

  get internalValues(): number[] {
    const ticks = this.tickValues;
    const normalized = Array.isArray(this.#args.value)
      ? this.#args.value.length === 0
        ? [ticks?.[0] ?? this.min]
        : this.#args.value
      : [this.#args.value ?? ticks?.[0] ?? this.min];

    if (ticks) {
      return normalized.map((v) => {
        const index = findNearestIndex(ticks, v);

        return clamp(index, this.internalMin, this.internalMax);
      });
    }

    return normalized.map((v) => clamp(roundToStep(v, this.step), this.min, this.max));
  }

  outputValuesFromInternal(internalValues: number[]): number[] {
    const ticks = this.tickValues;

    if (!ticks) return internalValues;

    return internalValues.map((internal) => {
      const index = clamp(Math.round(internal), 0, ticks.length - 1);

      return ticks[index] ?? ticks[0] ?? this.min;
    });
  }

  get values(): number[] {
    return this.outputValuesFromInternal(this.internalValues);
  }

  get thumbs(): SliderThumb[] {
    this.#ensureThumbCount(this.internalValues.length);

    return this.#thumbStates;
  }

  get thumbPercents(): number[] {
    return this.internalValues.map((value) =>
      getPercentage(value, this.internalMin, this.internalMax)
    );
  }

  get isMulti(): boolean {
    return this.internalValues.length > 1;
  }

  thumbPositionStyle = (percent: number): StyleString => {
    const property = this.orientation === 'horizontal' ? 'left' : 'bottom';

    return htmlSafe(`${property}: ${percent}%`);
  };

  get rangeStyle(): StyleString {
    const internalValues = this.internalValues;

    // For a single-thumb slider, the "range" should fill from the start to the
    // thumb. For multi-thumb, it fills between the min/max thumbs.
    const internalRangeMin =
      internalValues.length <= 1 ? this.internalMin : Math.min(...internalValues);
    const internalRangeMax =
      internalValues[0] === undefined ? this.internalMin : Math.max(...internalValues);
    const startPercent = getPercentage(internalRangeMin, this.internalMin, this.internalMax);
    const endPercent = getPercentage(internalRangeMax, this.internalMin, this.internalMax);

    if (this.orientation === 'horizontal') {
      return htmlSafe(`left: ${startPercent}%; right: ${100 - endPercent}%`);
    } else {
      return htmlSafe(`bottom: ${startPercent}%; top: ${100 - endPercent}%`);
    }
  }

  updateValue = (newValues: number[]) => {
    if (this.#args.onValueChange) {
      this.#args.onValueChange(this.coerceOutput(newValues));
    }
  };

  commitValue = (newValues: number[]) => {
    if (this.#args.onValueCommit) {
      this.#args.onValueCommit(this.coerceOutput(newValues));
    }
  };

  coerceOutput(values: number[]): number | number[] {
    if (Array.isArray(this.#args.value)) return values;

    return values[0] ?? this.min;
  }

  #ensureThumbCount(count: number) {
    if (count === this.#thumbStates.length) return;

    if (count < this.#thumbStates.length) {
      this.#thumbStates = this.#thumbStates.slice(0, count);

      return;
    }

    const startIndex = this.#thumbStates.length;

    for (let index = startIndex; index < count; index++) {
      this.#thumbStates.push(new ThumbState(this, index));
    }
  }

  #applyThumbInternalValue(index: number, rawValue: number): number[] {
    const nextValues = [...this.internalValues];
    const stepped = clamp(
      roundToStep(rawValue, this.internalStep),
      this.internalMin,
      this.internalMax
    );

    let constrained = stepped;

    if (nextValues.length > 1) {
      const prev = nextValues[index - 1];
      const next = nextValues[index + 1];

      if (prev !== undefined) constrained = Math.max(constrained, prev);
      if (next !== undefined) constrained = Math.min(constrained, next);
    }

    nextValues[index] = constrained;

    return nextValues;
  }

  handleThumbInput = (index: number, value: number) => {
    if (this.disabled) return;

    const internalValues = this.#applyThumbInternalValue(index, value);
    const newValues = this.outputValuesFromInternal(internalValues);

    this.updateValue(newValues);
  };

  handleThumbChange = (index: number, value: number) => {
    if (this.disabled) return;

    const internalValues = this.#applyThumbInternalValue(index, value);
    const newValues = this.outputValuesFromInternal(internalValues);

    this.updateValue(newValues);
    this.commitValue(newValues);
  };

  handleThumbActivate = (index: number) => {
    this.activeThumbIndex = index;
  };

  defaultThumbLabel = (index: number): string => {
    const count = this.internalValues.length;

    if (count <= 1) return 'Value';
    if (count === 2) return index === 0 ? 'Minimum' : 'Maximum';

    return `Value ${index + 1}`;
  };
}


---

import "./styles.css";

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { buildWaiter, waitForPromise } from "@ember/test-waiters";
import { isTesting, macroCondition } from "@embroider/macros";

import { modifier } from "ember-modifier";

import type { ScrollBehavior, Signature } from "./types.ts";

const testWaiter = buildWaiter("ember-primitive:zoetrope-waiter");
const DEFAULT_GAP = 8;
const DEFAULT_OFFSET = 0;

export class Zoetrope extends Component<Signature> {
  @tracked scrollerElement: HTMLElement | null = null;
  @tracked currentlyScrolled = 0;
  @tracked scrollWidth = 0;
  @tracked offsetWidth = 0;

  private setCSSVariables = modifier(
    (element: HTMLElement, _: unknown, { gap, offset }: { gap: number; offset: number }) => {
      if (gap) element.style.setProperty("--zoetrope-gap", `${gap}px`);
      if (offset) element.style.setProperty("--zoetrope-offset", `${offset}px`);
    },
  );

  scrollerWaiter = testWaiter.beginAsync();
  noScrollWaiter = () => {
    testWaiter.endAsync(this.scrollerWaiter);
  };

  private configureScroller = modifier((element: HTMLElement) => {
    this.scrollerElement = element;
    this.currentlyScrolled = element.scrollLeft;

    const zoetropeResizeObserver = new ResizeObserver(() => {
      this.scrollWidth = element.scrollWidth;
      this.offsetWidth = element.offsetWidth;
    });

    zoetropeResizeObserver.observe(element);

    element.addEventListener("scroll", this.scrollListener, { passive: true });
    element.addEventListener("keydown", this.tabListener);

    requestAnimationFrame(() => {
      testWaiter.endAsync(this.scrollerWaiter);
    });

    return () => {
      element.removeEventListener("scroll", this.scrollListener);
      element.removeEventListener("keydown", this.tabListener);

      zoetropeResizeObserver.unobserve(element);
    };
  });

  private tabListener = (event: KeyboardEvent) => {
    const target = event.target as HTMLElement;
    const { key, shiftKey } = event;

    if (!this.scrollerElement || this.scrollerElement === target) {
      return;
    }

    if (key !== "Tab") {
      return;
    }

    const nextElement = target.nextElementSibling;
    const previousElement = target.previousElementSibling;

    if ((!shiftKey && !nextElement) || (shiftKey && !previousElement)) {
      return;
    }

    event.preventDefault();

    let newTarget: HTMLElement | null = null;

    if (shiftKey) {
      newTarget = previousElement as HTMLElement;
    } else {
      newTarget = nextElement as HTMLElement;
    }

    if (!newTarget) {
      return;
    }

    newTarget?.focus({ preventScroll: true });

    const rect = getRelativeBoundingClientRect(newTarget, this.scrollerElement);

    this.scrollerElement?.scrollBy({
      left: rect.left,
      behavior: this.scrollBehavior,
    });
  };

  private scrollListener = () => {
    this.currentlyScrolled = this.scrollerElement?.scrollLeft || 0;
  };

  get offset() {
    return this.args.offset ?? DEFAULT_OFFSET;
  }

  get gap() {
    return this.args.gap ?? DEFAULT_GAP;
  }

  get canScroll() {
    return this.scrollWidth > this.offsetWidth + this.offset;
  }

  get cannotScrollLeft() {
    return this.currentlyScrolled <= this.offset;
  }

  get cannotScrollRight() {
    return this.scrollWidth - this.offsetWidth - this.offset < this.currentlyScrolled;
  }

  get scrollBehavior(): ScrollBehavior {
    if (macroCondition(isTesting())) {
      return "instant";
    }

    return this.args.scrollBehavior || "smooth";
  }

  scrollLeft = () => {
    if (!(this.scrollerElement instanceof HTMLElement)) {
      return;
    }

    const { firstChild } = this.findOverflowingElement();

    if (!firstChild) {
      return;
    }

    const children = [...this.scrollerElement.children];

    const firstChildIndex = children.indexOf(firstChild);

    let targetElement = firstChild;
    let accumalatedWidth = 0;

    for (let i = firstChildIndex; i >= 0; i--) {
      const child = children[i];

      if (!(child instanceof HTMLElement)) {
        continue;
      }

      accumalatedWidth += child.offsetWidth + this.gap;

      if (accumalatedWidth >= this.offsetWidth) {
        break;
      }

      targetElement = child;
    }

    const rect = getRelativeBoundingClientRect(targetElement, this.scrollerElement);

    this.scrollerElement.scrollBy({
      left: rect.left,
      behavior: this.scrollBehavior,
    });

    void waitForPromise(new Promise(requestAnimationFrame));
  };

  scrollRight = () => {
    if (!(this.scrollerElement instanceof HTMLElement)) {
      return;
    }

    const { activeSlide, lastChild } = this.findOverflowingElement();

    if (!lastChild) {
      return;
    }

    let rect = getRelativeBoundingClientRect(lastChild, this.scrollerElement);

    // If the card is larger than the container then skip to the next card
    if (rect.width > this.offsetWidth && activeSlide === lastChild) {
      const children = [...this.scrollerElement.children];
      const lastChildIndex = children.indexOf(lastChild);
      const targetElement = children[lastChildIndex + 1];

      if (!targetElement) {
        return;
      }

      rect = getRelativeBoundingClientRect(targetElement, this.scrollerElement);
    }

    this.scrollerElement?.scrollBy({
      left: rect.left,
      behavior: this.scrollBehavior,
    });

    void waitForPromise(new Promise(requestAnimationFrame));
  };

  private findOverflowingElement() {
    const returnObj: {
      activeSlide?: Element;
      firstChild?: Element;
      lastChild?: Element;
    } = {
      firstChild: undefined,
      lastChild: undefined,
      activeSlide: undefined,
    };

    if (!this.scrollerElement) {
      return returnObj;
    }

    const parentElement = this.scrollerElement.parentElement;

    if (!parentElement) {
      return returnObj;
    }

    const containerRect = getRelativeBoundingClientRect(this.scrollerElement, parentElement);

    const children = [...this.scrollerElement.children];

    // Find the first child that is overflowing the left edge of the container
    // and the last child that is overflowing the right edge of the container
    for (const child of children) {
      const rect = getRelativeBoundingClientRect(child, this.scrollerElement);

      if (rect.right + this.gap >= containerRect.left && !returnObj.firstChild) {
        returnObj.firstChild = child;
      }

      if (rect.left >= this.offset && !returnObj.activeSlide) {
        returnObj.activeSlide = child;
      }

      if (rect.right >= containerRect.width && !returnObj.lastChild) {
        returnObj.lastChild = child;

        break;
      }
    }

    if (!returnObj.firstChild) {
      returnObj.firstChild = children[0];
    }

    if (!returnObj.lastChild) {
      returnObj.lastChild = children[children.length - 1];
    }

    return returnObj;
  }

  <template>
    <section
      class="ember-primitives__zoetrope"
      {{this.setCSSVariables gap=this.gap offset=this.offset}}
      ...attributes
    >
      {{#if (has-block "header")}}
        <div class="ember-primitives__zoetrope__header">
          {{yield to="header"}}
        </div>
      {{/if}}

      {{#if (has-block "controls")}}
        {{yield
          (hash
            cannotScrollLeft=this.cannotScrollLeft
            cannotScrollRight=this.cannotScrollRight
            canScroll=this.canScroll
            scrollLeft=this.scrollLeft
            scrollRight=this.scrollRight
          )
          to="controls"
        }}
      {{else}}
        {{#if this.canScroll}}
          <div class="ember-primitives__zoetrope__controls">
            <button
              type="button"
              {{on "click" this.scrollLeft}}
              disabled={{this.cannotScrollLeft}}
            >Left</button>

            <button
              type="button"
              {{on "click" this.scrollRight}}
              disabled={{this.cannotScrollRight}}
            >Right</button>
          </div>
        {{/if}}
      {{/if}}
      {{#if (has-block "content")}}
        <div class="ember-primitives__zoetrope__scroller" {{this.configureScroller}}>
          {{yield to="content"}}
        </div>
      {{else}}
        {{(this.noScrollWaiter)}}
      {{/if}}
    </section>
  </template>
}

export default Zoetrope;

function getRelativeBoundingClientRect(childElement: Element, parentElement: Element) {
  if (!childElement || !parentElement) {
    throw new Error("Both childElement and parentElement must be provided");
  }

  // Get the bounding rect of the child and parent elements
  const childRect = childElement.getBoundingClientRect();
  const parentRect = parentElement.getBoundingClientRect();

  // Get computed styles of the parent element
  const parentStyles = window.getComputedStyle(parentElement);

  // Extract and parse parent's padding, and border, for all sides
  const parentPaddingTop = parseFloat(parentStyles.paddingTop);
  const parentPaddingLeft = parseFloat(parentStyles.paddingLeft);

  const parentBorderTopWidth = parseFloat(parentStyles.borderTopWidth);
  const parentBorderLeftWidth = parseFloat(parentStyles.borderLeftWidth);

  // Calculate child's position relative to parent's content area (including padding and borders)
  return {
    width: childRect.width,
    height: childRect.height,
    top: childRect.top - parentRect.top - parentBorderTopWidth - parentPaddingTop,
    left: childRect.left - parentRect.left - parentBorderLeftWidth - parentPaddingLeft,
    bottom:
      childRect.top - parentRect.top - parentBorderTopWidth - parentPaddingTop + childRect.height,
    right:
      childRect.left -
      parentRect.left -
      parentBorderLeftWidth -
      parentPaddingLeft +
      childRect.width,
  };
}


---

.ember-primitives__zoetrope {
  display: flex;
  flex-wrap: wrap;
  position: relative;
  width: 100%;
}

.ember-primitives__zoetrope__header {
  align-items: center;
  display: flex;
  flex: 1;
  justify-content: space-between;
  padding-left: var(--zoetrope-offset, 0);
}

.ember-primitives__zoetrope__controls {
  align-items: center;
  display: flex;
  padding-right: var(--zoetrope-offset, 0);
  gap: 4px;
}

.ember-primitives__zoetrope__scroller {
  display: flex;
  flex-flow: row nowrap;
  gap: var(--zoetrope-gap, 8px);
  overflow: scroll visible;
  padding: 8px var(--zoetrope-offset, 0);
  scroll-behavior: smooth;
  scroll-padding-left: var(--zoetrope-offset, 0);
  scroll-snap-type: x mandatory;
  scrollbar-color: transparent transparent;
  scrollbar-width: none;
  width: 100%;

  & > * {
    flex-shrink: 0;
    scroll-snap-align: start;
  }
}


---

export type ScrollBehavior = 'auto' | 'smooth' | 'instant';

export interface Signature {
  Args: {
    /**
     * The distance in pixels between each item in the slider.
     */
    gap?: number;

    /**
     * The distance from the edge of the container to the first and last item, this allows
     * the contents to visually overflow the container
     */
    offset?: number;

    /**
     * The scroll behavior to use when scrolling the slider. Defaults to smooth.
     */
    scrollBehavior?: ScrollBehavior;
  };
  Blocks: {
    /**
     * The header block is where the header content is placed.
     */
    header: [];

    /**
     * The content block is where the items that will be scrolled are placed.
     */
    content: [];

    /**
     * The controls block is where the left and right buttons are placed.
     */
    controls: [
      {
        /**
         * Whether the slider can scroll.
         */
        canScroll: boolean;

        /**
         * Whether the slider cannot scroll left.
         */
        cannotScrollLeft: boolean;

        /**
         * Whether the slider cannot scroll right.
         */
        cannotScrollRight: boolean;

        /**
         * The function to scroll the slider left.
         */
        scrollLeft: () => void;

        /**
         * The function to scroll the slider right.
         */
        scrollRight: () => void;
      },
    ];
  };
  Element: HTMLElement;
}


---

