@sken-ds/primitives 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"sken-layout.js","names":["#variantHasSidebar"],"sources":["../src/components/sken-layout.ts"],"sourcesContent":["// ── <sken-layout> — structural layout primitive ────────────────────\n// Defines the page-level structure of a multi-region interface\n// (topbar, primary nav, sidebar, content, footer) without imposing\n// what lives inside each region. Composition by contract:\n//\n// <sken-layout variant=\"header-sidebar\">\n// <sken-layout-region name=\"topbar\">…</sken-layout-region>\n// <sken-layout-region name=\"primary-nav\">…</sken-layout-region>\n// <sken-layout-region name=\"sidebar\">…</sken-layout-region>\n// <sken-layout-region name=\"content\">…</sken-layout-region>\n// </sken-layout>\n//\n// Design notes:\n// - We project the light-DOM regions into a shadow-DOM grid via\n// a single <slot>. Inside the shadow we own the grid template\n// + per-name `grid-area` CSS rules; the consumer's regions\n// sit in the slot. Empty regions (no consumer region for a\n// name) simply do not project; the grid still has the area\n// declared but the slot stays empty, so no space is reserved.\n// - We DO NOT use named <slot name=\"topbar\"> etc. because that\n// would force the consumer to keep the regions in a fixed\n// order. A single unnamed <slot> lets the consumer arrange\n// the regions in any order; the grid-area CSS rule per name\n// is what places them in the layout. (A Vue adapter that\n// renders regions via <template v-for> is therefore free.)\n// - For roles the active variant does not include, we hide\n// the region via `display: none` so a consumer that always\n// renders the same set of regions (e.g. from a layout\n// component shared across pages) does not get phantom space.\n// The CSS rule uses `:host([variant=…])` so it does not\n// depend on JS state.\n//\n// See ADR-0006 for the substrate decision. See the\n// @sken-ds/contracts types `SkenLayoutProps`, `SkenLayoutSlots`,\n// `SkenLayoutVariant` for the contract surface.\n\nimport { LitElement, html, css, unsafeCSS } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\nimport type {\n SkenLayoutVariant,\n SkenLayoutTopbarMode,\n SkenLayoutSidebarPosition,\n SkenLayoutSidebarSize,\n} from '@sken-ds/contracts'\nimport './sken-layout-region.js'\n\n/**\n * Sidebar width in `rem` per size concept. The product can\n * override with `--sken-layout-sidebar-width` (CSS custom\n * property) if it needs a different value.\n */\nconst SIDEBAR_WIDTH: Record<SkenLayoutSidebarSize, string> = {\n sm: '12rem',\n md: '16rem',\n lg: '20rem',\n}\n\n@customElement('sken-layout')\nexport class SkenLayout extends LitElement {\n @property({ reflect: true }) variant: SkenLayoutVariant = 'header-content'\n @property({ attribute: 'topbar-mode', reflect: true }) topbarMode: SkenLayoutTopbarMode = 'fixed'\n @property({ attribute: 'sidebar-position', reflect: true })\n sidebarPosition: SkenLayoutSidebarPosition = 'start'\n @property({ attribute: 'sidebar-size', reflect: true }) sidebarSize: SkenLayoutSidebarSize = 'md'\n\n static styles = css`\n /* ── The host ────────────────────────────────────────────────\n The host owns the page-level grid. The body row is a\n sub-grid; the topbar / primary-nav / footer sit in their\n own rows. The shadow DOM owns the layout; the slot inside\n projects the consumer's regions into the grid cells. */\n :host {\n display: grid;\n block-size: 100dvh;\n grid-template-columns: 1fr;\n grid-template-rows: auto 1fr;\n grid-template-areas:\n 'topbar'\n 'body';\n background: var(--sken-background, Canvas);\n color: var(--sken-foreground, CanvasText);\n font-family: var(--sken-family-sans, system-ui, sans-serif);\n }\n\n .body {\n display: grid;\n grid-template-columns: 1fr;\n grid-template-areas: 'content';\n min-block-size: 0; /* allow children to shrink + scroll */\n }\n\n .body.with-sidebar {\n grid-template-columns: var(--sken-layout-sidebar-width, 16rem) 1fr;\n grid-template-areas: 'sidebar content';\n }\n .body.with-sidebar.sidebar-end {\n grid-template-columns: 1fr var(--sken-layout-sidebar-width, 16rem);\n grid-template-areas: 'content sidebar';\n }\n\n /* ── Per-region grid placement ───────────────────────────────\n The light-DOM <sken-layout-region> children are projected\n here through the unnamed <slot>. The grid-area rule per\n name positions each one in the right cell. We select the\n slotted custom elements by attribute, which works\n transparently across the shadow boundary. */\n ::slotted(sken-layout-region[name='topbar']) {\n grid-area: topbar;\n }\n ::slotted(sken-layout-region[name='primary-nav']) {\n grid-area: primary-nav;\n }\n ::slotted(sken-layout-region[name='sidebar']) {\n grid-area: sidebar;\n min-block-size: 0;\n border-inline-end: 1px solid\n var(--sken-border, color-mix(in srgb, currentColor 12%, transparent));\n }\n ::slotted(sken-layout-region[name='content']) {\n grid-area: content;\n min-block-size: 0;\n }\n ::slotted(sken-layout-region[name='footer']) {\n grid-area: footer;\n border-top: 1px solid\n var(--sken-border, color-mix(in srgb, currentColor 12%, transparent));\n }\n\n /* ── Grid templates per variant ─────────────────────────────── */\n :host([variant='full-bleed']) {\n grid-template-areas: 'body';\n grid-template-rows: 1fr;\n }\n :host([variant='header-content']) {\n grid-template-areas:\n 'topbar'\n 'body';\n grid-template-rows: auto 1fr;\n }\n :host([variant='sidebar-content']) {\n grid-template-rows: 1fr;\n grid-template-areas: 'body';\n }\n :host([variant='sidebar-content']) .body {\n grid-template-columns: var(--sken-layout-sidebar-width, 16rem) 1fr;\n grid-template-areas: 'sidebar content';\n }\n :host([variant='header-sidebar']) {\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n 'topbar'\n 'primary-nav'\n 'body';\n }\n :host([variant='header-sidebar-footer']) {\n grid-template-rows: auto auto 1fr auto;\n grid-template-areas:\n 'topbar'\n 'primary-nav'\n 'body'\n 'footer';\n }\n\n /* ── Topbar mode ──────────────────────────────────────────────\n 'fixed': the host is 100dvh and the body scrolls under the\n topbar. This is the default for admin consoles.\n 'auto': the topbar scrolls away with the page (marketing,\n landing). */\n :host([topbar-mode='auto']) {\n block-size: auto;\n min-block-size: 100dvh;\n }\n\n /* ── Sidebar position ─────────────────────────────────────────\n The 'with-sidebar' class is applied via JS in render() so\n we can include the 'sidebar-end' modifier in the same\n selector chain. (You cannot combine the [sidebar-position]\n attribute with the '.with-sidebar' class in a single\n selector, because the class is conditional.) */\n :host([sidebar-position='end']) ::slotted(sken-layout-region[name='sidebar']) {\n border-inline-end: none;\n border-inline-start: 1px solid\n var(--sken-border, color-mix(in srgb, currentColor 12%, transparent));\n }\n\n /* ── Sidebar width override ─────────────────────────────────── */\n :host([sidebar-size='sm']) {\n --sken-layout-sidebar-width: ${unsafeCSS(SIDEBAR_WIDTH.sm)};\n }\n :host([sidebar-size='md']) {\n --sken-layout-sidebar-width: ${unsafeCSS(SIDEBAR_WIDTH.md)};\n }\n :host([sidebar-size='lg']) {\n --sken-layout-sidebar-width: ${unsafeCSS(SIDEBAR_WIDTH.lg)};\n }\n\n /* ── Variant-aware region hiding ─────────────────────────────\n Roles the active variant does not include are hidden\n outright. This way a consumer that always renders the\n same set of regions (e.g. a shared layout component) does\n not get phantom space when a page uses a different\n variant. */\n :host([variant='full-bleed']) ::slotted(sken-layout-region[name='topbar']),\n :host([variant='full-bleed']) ::slotted(sken-layout-region[name='primary-nav']),\n :host([variant='full-bleed']) ::slotted(sken-layout-region[name='sidebar']),\n :host([variant='full-bleed']) ::slotted(sken-layout-region[name='footer']) {\n display: none;\n }\n\n :host([variant='header-content']) ::slotted(sken-layout-region[name='primary-nav']),\n :host([variant='header-content']) ::slotted(sken-layout-region[name='sidebar']),\n :host([variant='header-content']) ::slotted(sken-layout-region[name='footer']) {\n display: none;\n }\n\n :host([variant='sidebar-content']) ::slotted(sken-layout-region[name='topbar']),\n :host([variant='sidebar-content']) ::slotted(sken-layout-region[name='primary-nav']),\n :host([variant='sidebar-content']) ::slotted(sken-layout-region[name='footer']) {\n display: none;\n }\n\n :host([variant='header-sidebar']) ::slotted(sken-layout-region[name='footer']) {\n display: none;\n }\n `\n\n override render() {\n const hasSidebar = this.#variantHasSidebar(this.variant)\n const bodyClasses = [\n 'body',\n hasSidebar ? 'with-sidebar' : '',\n hasSidebar && this.sidebarPosition === 'end' ? 'sidebar-end' : '',\n ]\n .filter(Boolean)\n .join(' ')\n\n return html`\n <div class=${bodyClasses} part=\"body\">\n <slot></slot>\n </div>\n `\n }\n\n /**\n * Single source of truth for which variants include a sidebar.\n * Used to apply the `.with-sidebar` class on the body grid\n * wrapper. Mirrors the per-variant CSS templates above.\n */\n #variantHasSidebar(variant: SkenLayoutVariant): boolean {\n return variant === 'sidebar-content' || variant === 'header-sidebar' || variant === 'header-sidebar-footer'\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-layout': SkenLayout\n }\n}\n"],"mappings":";;;;;AAmDA,IAAM,IAAuD;CAC3D,IAAI;CACJ,IAAI;CACJ,IAAI;AACN,GAGa,IAAN,cAAyB,EAAW;;EAKoD,aAJnC,KAAA,UAAA,kBACgC,KAAA,aAAA,SAE7C,KAAA,kBAAA,SACgD,KAAA,cAAA;;;EAE7E,KAAA,SAAA,CAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qCA0HgB,EAAU,EAAc,EAAE,EAAE;;;qCAG5B,EAAU,EAAc,EAAE,EAAE;;;qCAG5B,EAAU,EAAc,EAAE,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiC/D,SAAkB;EAChB,IAAM,IAAa,KAAKA,GAAmB,KAAK,OAAO,GACjD,IAAc;GAClB;GACA,IAAa,iBAAiB;GAC9B,KAAc,KAAK,oBAAoB,QAAQ,gBAAgB;EACjE,CAAC,CACE,OAAO,OAAO,CAAC,CACf,KAAK,GAAG;EAEX,OAAO,CAAI;mBACI,EAAY;;;;CAI7B;CAOA,GAAmB,GAAqC;EACtD,OAAO,MAAY,qBAAqB,MAAY,oBAAoB,MAAY;CACtF;AACF;AAhMG,EAAA,CAAA,EAAS,EAAE,SAAS,GAAK,CAAC,CAAA,GAAA,EAAA,WAAA,WAAA,KAAA,CAAA,GAC1B,EAAA,CAAA,EAAS;CAAE,WAAW;CAAe,SAAS;AAAK,CAAC,CAAA,GAAA,EAAA,WAAA,cAAA,KAAA,CAAA,GACpD,EAAA,CAAA,EAAS;CAAE,WAAW;CAAoB,SAAS;AAAK,CAAC,CAAA,GAAA,EAAA,WAAA,mBAAA,KAAA,CAAA,GAEzD,EAAA,CAAA,EAAS;CAAE,WAAW;CAAgB,SAAS;AAAK,CAAC,CAAA,GAAA,EAAA,WAAA,eAAA,KAAA,CAAA,GANvD,IAAA,EAAA,CAAA,EAAc,aAAa,CAAA,GAAA,CAAA"}
1
+ {"version":3,"file":"sken-layout.js","names":["#variantHasSidebar"],"sources":["../src/components/sken-layout.ts"],"sourcesContent":["// ── <sken-layout> — structural layout primitive ────────────────────\n// Defines the page-level structure of a multi-region interface\n// (topbar, primary nav, sidebar, content, footer) without imposing\n// what lives inside each region. Composition by contract:\n//\n// <sken-layout variant=\"header-sidebar\">\n// <sken-layout-region name=\"topbar\">…</sken-layout-region>\n// <sken-layout-region name=\"primary-nav\">…</sken-layout-region>\n// <sken-layout-region name=\"sidebar\">…</sken-layout-region>\n// <sken-layout-region name=\"content\">…</sken-layout-region>\n// </sken-layout>\n//\n// Design notes:\n// - We project the light-DOM regions into a shadow-DOM grid via\n// a single <slot>. Inside the shadow we own the grid template\n// + per-name `grid-area` CSS rules; the consumer's regions\n// sit in the slot. Empty regions (no consumer region for a\n// name) simply do not project; the grid still has the area\n// declared but the slot stays empty, so no space is reserved.\n// - We DO NOT use named <slot name=\"topbar\"> etc. because that\n// would force the consumer to keep the regions in a fixed\n// order. A single unnamed <slot> lets the consumer arrange\n// the regions in any order; the grid-area CSS rule per name\n// is what places them in the layout. (A Vue adapter that\n// renders regions via <template v-for> is therefore free.)\n// - For roles the active variant does not include, we hide\n// the region via `display: none` so a consumer that always\n// renders the same set of regions (e.g. from a layout\n// component shared across pages) does not get phantom space.\n// The CSS rule uses `:host([variant=…])` so it does not\n// depend on JS state.\n//\n// See ADR-0006 for the substrate decision. See the\n// @sken-ds/contracts types `SkenLayoutProps`, `SkenLayoutSlots`,\n// `SkenLayoutVariant` for the contract surface.\n\nimport { LitElement, html, css, unsafeCSS } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\nimport type {\n SkenLayoutVariant,\n SkenLayoutTopbarMode,\n SkenLayoutSidebarPosition,\n SkenLayoutSidebarSize,\n SkenLayoutDensity,\n} from '@sken-ds/contracts'\nimport './sken-layout-region.js'\n\n/**\n * Sidebar width in `rem` per size concept. The product can\n * override with `--sken-layout-sidebar-width` (CSS custom\n * property) if it needs a different value.\n */\nconst SIDEBAR_WIDTH: Record<SkenLayoutSidebarSize, string> = {\n sm: '12rem',\n md: '16rem',\n lg: '20rem',\n}\n\n/**\n * Per-cell background tokens. The values reference the\n * Sken color tokens, not raw colors, so a theme switch\n * (`data-theme=\"dark\"`) recolors every cell without the\n * primitive knowing about it.\n *\n * Mapping rationale:\n * - topbar is on `--sken-card` (white) because it is the\n * \"elevated\" chrome; the border-bottom separates it from\n * the page below.\n * - primary-nav is on `--sken-background` (page colour) so\n * it sits flush with the page; the border-bottom separates\n * it from the workspace.\n * - sidebar is on `--sken-muted` (subtle off-white) with a\n * border-inline-end; it is a \"rail\", not a \"page\".\n * - content is on `--sken-background`; no border (the\n * workspace is unbounded).\n * - footer is on `--sken-muted` with a border-top; it is\n * the same role as the sidebar (chrome).\n */\nconst REGION_BACKGROUND: Record<string, string> = {\n topbar: 'var(--sken-card)',\n 'primary-nav': 'var(--sken-background)',\n sidebar: 'var(--sken-muted)',\n content: 'var(--sken-background)',\n footer: 'var(--sken-muted)',\n}\n\n@customElement('sken-layout')\nexport class SkenLayout extends LitElement {\n @property({ reflect: true }) variant: SkenLayoutVariant = 'header-content'\n @property({ attribute: 'topbar-mode', reflect: true }) topbarMode: SkenLayoutTopbarMode = 'fixed'\n @property({ attribute: 'sidebar-position', reflect: true })\n sidebarPosition: SkenLayoutSidebarPosition = 'start'\n @property({ attribute: 'sidebar-size', reflect: true }) sidebarSize: SkenLayoutSidebarSize = 'md'\n @property({ reflect: true }) density: SkenLayoutDensity = 'none'\n\n static styles = css`\n /* ── The host ────────────────────────────────────────────────\n The host owns the page-level grid. The body row is a\n sub-grid; the topbar / primary-nav / footer sit in their\n own rows. The shadow DOM owns the layout; the slot inside\n projects the consumer's regions into the grid cells. */\n :host {\n display: grid;\n block-size: 100dvh;\n grid-template-columns: 1fr;\n grid-template-rows: auto 1fr;\n grid-template-areas:\n 'topbar'\n 'body';\n background: var(--sken-background, Canvas);\n color: var(--sken-foreground, CanvasText);\n font-family: var(--sken-family-sans, system-ui, sans-serif);\n }\n\n .body {\n display: grid;\n grid-template-columns: 1fr;\n grid-template-areas: 'content';\n min-block-size: 0; /* allow children to shrink + scroll */\n }\n\n .body.with-sidebar {\n grid-template-columns: var(--sken-layout-sidebar-width, 16rem) 1fr;\n grid-template-areas: 'sidebar content';\n }\n .body.with-sidebar.sidebar-end {\n grid-template-columns: 1fr var(--sken-layout-sidebar-width, 16rem);\n grid-template-areas: 'content sidebar';\n }\n\n /* ── Per-region grid placement + DS-driven surfaces ──────────\n The light-DOM <sken-layout-region> children are projected\n here through the unnamed <slot>. The grid-area rule per\n name positions each one in the right cell. We select the\n slotted custom elements by attribute, which works\n transparently across the shadow boundary.\n\n For each cell we apply:\n - background color: the Sken surface token that matches\n the cell's role (see REGION_BACKGROUND above). The\n product can override per cell via\n --sken-layout-region-{role}-background.\n - padding: ZERO by default. The consumer decides\n whether the topbar / sidebar / content / etc. need\n padding and how much. This matches the iX\n convention: the layout owns the grid + chrome\n (background, border), and the cell contents own\n the padding. Set density=comfortable or\n density=compact to opt into the Sken default\n padding scale. Per-cell override via\n --sken-layout-region-{role}-padding-{block|inline}\n (always wins over density).\n - border: a single edge in the role's expected\n direction (border-bottom for chrome above the\n workspace, border-top for chrome below,\n border-inline-end for the sidebar on the start\n side). The sidebar swap for sidebar-position=\"end\"\n is below.\n - min-block-size: 0 so a long content area can scroll\n inside the host's 100dvh viewport instead of pushing\n the layout out. */\n ::slotted(sken-layout-region[name='topbar']) {\n grid-area: topbar;\n background: var(--sken-layout-region-topbar-background, ${unsafeCSS(REGION_BACKGROUND.topbar)});\n padding-block: var(--sken-layout-region-topbar-padding-block, 0);\n padding-inline: var(--sken-layout-region-topbar-padding-inline, 0);\n border-bottom: 1px solid var(--sken-border);\n }\n ::slotted(sken-layout-region[name='primary-nav']) {\n grid-area: primary-nav;\n background: var(\n --sken-layout-region-primary-nav-background,\n ${unsafeCSS(REGION_BACKGROUND['primary-nav'])}\n );\n padding-block: var(--sken-layout-region-primary-nav-padding-block, 0);\n padding-inline: var(--sken-layout-region-primary-nav-padding-inline, 0);\n border-bottom: 1px solid var(--sken-border);\n }\n ::slotted(sken-layout-region[name='sidebar']) {\n grid-area: sidebar;\n min-block-size: 0;\n background: var(--sken-layout-region-sidebar-background, ${unsafeCSS(REGION_BACKGROUND.sidebar)});\n padding-block: var(--sken-layout-region-sidebar-padding-block, 0);\n padding-inline: var(--sken-layout-region-sidebar-padding-inline, 0);\n border-inline-end: 1px solid var(--sken-border);\n }\n ::slotted(sken-layout-region[name='content']) {\n grid-area: content;\n min-block-size: 0;\n background: var(--sken-layout-region-content-background, ${unsafeCSS(REGION_BACKGROUND.content)});\n padding-block: var(--sken-layout-region-content-padding-block, 0);\n padding-inline: var(--sken-layout-region-content-padding-inline, 0);\n }\n ::slotted(sken-layout-region[name='footer']) {\n grid-area: footer;\n background: var(--sken-layout-region-footer-background, ${unsafeCSS(REGION_BACKGROUND.footer)});\n padding-block: var(--sken-layout-region-footer-padding-block, 0);\n padding-inline: var(--sken-layout-region-footer-padding-inline, 0);\n border-top: 1px solid var(--sken-border);\n }\n\n /* ── Density: comfortable ─────────────────────────────────────\n When density=comfortable, the layout applies the Sken\n default rhythm: 8/16px on chrome cells, 16/24px on the\n content area, 12/16px on the footer. The values come\n from the spacing scale (--sken-2, --sken-3, --sken-4,\n --sken-6) so a single token change ripples through\n every cell. A consumer override on any\n --sken-layout-region-{role}-padding-{block|inline}\n always wins over the density preset. */\n :host([density='comfortable']) ::slotted(sken-layout-region) {\n --sken-layout-region-topbar-padding-block: var(--sken-2);\n --sken-layout-region-topbar-padding-inline: var(--sken-4);\n --sken-layout-region-primary-nav-padding-block: var(--sken-2);\n --sken-layout-region-primary-nav-padding-inline: var(--sken-4);\n --sken-layout-region-sidebar-padding-block: var(--sken-4);\n --sken-layout-region-sidebar-padding-inline: var(--sken-4);\n --sken-layout-region-content-padding-block: var(--sken-6);\n --sken-layout-region-content-padding-inline: var(--sken-6);\n --sken-layout-region-footer-padding-block: var(--sken-3);\n --sken-layout-region-footer-padding-inline: var(--sken-4);\n }\n\n /* ── Density: compact ────────────────────────────────────────\n When density=compact, every per-cell padding is halved\n from the comfortable value. We use calc(var(--sken-N) *\n 0.5) instead of hardcoded half-values so a future\n token change automatically cascades. As with\n comfortable, a consumer override on any\n --sken-layout-region-{role}-padding-{block|inline}\n always wins. */\n :host([density='compact']) ::slotted(sken-layout-region) {\n --sken-layout-region-topbar-padding-block: calc(var(--sken-2) * 0.5);\n --sken-layout-region-topbar-padding-inline: calc(var(--sken-4) * 0.5);\n --sken-layout-region-primary-nav-padding-block: calc(var(--sken-2) * 0.5);\n --sken-layout-region-primary-nav-padding-inline: calc(var(--sken-4) * 0.5);\n --sken-layout-region-sidebar-padding-block: calc(var(--sken-4) * 0.5);\n --sken-layout-region-sidebar-padding-inline: calc(var(--sken-4) * 0.5);\n --sken-layout-region-content-padding-block: calc(var(--sken-6) * 0.5);\n --sken-layout-region-content-padding-inline: calc(var(--sken-6) * 0.5);\n --sken-layout-region-footer-padding-block: calc(var(--sken-3) * 0.5);\n --sken-layout-region-footer-padding-inline: calc(var(--sken-4) * 0.5);\n }\n\n /* ── Grid templates per variant ─────────────────────────────── */\n :host([variant='full-bleed']) {\n grid-template-areas: 'body';\n grid-template-rows: 1fr;\n }\n :host([variant='header-content']) {\n grid-template-areas:\n 'topbar'\n 'body';\n grid-template-rows: auto 1fr;\n }\n :host([variant='sidebar-content']) {\n grid-template-rows: 1fr;\n grid-template-areas: 'body';\n }\n :host([variant='sidebar-content']) .body {\n grid-template-columns: var(--sken-layout-sidebar-width, 16rem) 1fr;\n grid-template-areas: 'sidebar content';\n }\n :host([variant='header-sidebar']) {\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n 'topbar'\n 'primary-nav'\n 'body';\n }\n :host([variant='header-sidebar-footer']) {\n grid-template-rows: auto auto 1fr auto;\n grid-template-areas:\n 'topbar'\n 'primary-nav'\n 'body'\n 'footer';\n }\n\n /* ── Topbar mode ──────────────────────────────────────────────\n 'fixed': the host is 100dvh and the body scrolls under the\n topbar. This is the default for admin consoles.\n 'auto': the topbar scrolls away with the page (marketing,\n landing). */\n :host([topbar-mode='auto']) {\n block-size: auto;\n min-block-size: 100dvh;\n }\n\n /* ── Sidebar position ─────────────────────────────────────────\n When the sidebar sits on the inline-end side, the border\n moves to the inline-start side. The default rule above\n already paints the border on the inline-end; here we\n swap it. */\n :host([sidebar-position='end']) ::slotted(sken-layout-region[name='sidebar']) {\n border-inline-end: none;\n border-inline-start: 1px solid var(--sken-border);\n }\n\n /* ── Sidebar width override ─────────────────────────────────── */\n :host([sidebar-size='sm']) {\n --sken-layout-sidebar-width: ${unsafeCSS(SIDEBAR_WIDTH.sm)};\n }\n :host([sidebar-size='md']) {\n --sken-layout-sidebar-width: ${unsafeCSS(SIDEBAR_WIDTH.md)};\n }\n :host([sidebar-size='lg']) {\n --sken-layout-sidebar-width: ${unsafeCSS(SIDEBAR_WIDTH.lg)};\n }\n\n /* ── Variant-aware region hiding ─────────────────────────────\n Roles the active variant does not include are hidden\n outright. This way a consumer that always renders the\n same set of regions (e.g. a shared layout component) does\n not get phantom space when a page uses a different\n variant. */\n :host([variant='full-bleed']) ::slotted(sken-layout-region[name='topbar']),\n :host([variant='full-bleed']) ::slotted(sken-layout-region[name='primary-nav']),\n :host([variant='full-bleed']) ::slotted(sken-layout-region[name='sidebar']),\n :host([variant='full-bleed']) ::slotted(sken-layout-region[name='footer']) {\n display: none;\n }\n\n :host([variant='header-content']) ::slotted(sken-layout-region[name='primary-nav']),\n :host([variant='header-content']) ::slotted(sken-layout-region[name='sidebar']),\n :host([variant='header-content']) ::slotted(sken-layout-region[name='footer']) {\n display: none;\n }\n\n :host([variant='sidebar-content']) ::slotted(sken-layout-region[name='topbar']),\n :host([variant='sidebar-content']) ::slotted(sken-layout-region[name='primary-nav']),\n :host([variant='sidebar-content']) ::slotted(sken-layout-region[name='footer']) {\n display: none;\n }\n\n :host([variant='header-sidebar']) ::slotted(sken-layout-region[name='footer']) {\n display: none;\n }\n `\n\n override render() {\n const hasSidebar = this.#variantHasSidebar(this.variant)\n const bodyClasses = [\n 'body',\n hasSidebar ? 'with-sidebar' : '',\n hasSidebar && this.sidebarPosition === 'end' ? 'sidebar-end' : '',\n ]\n .filter(Boolean)\n .join(' ')\n\n return html`\n <div class=${bodyClasses} part=\"body\">\n <slot></slot>\n </div>\n `\n }\n\n /**\n * Single source of truth for which variants include a sidebar.\n * Used to apply the `.with-sidebar` class on the body grid\n * wrapper. Mirrors the per-variant CSS templates above.\n */\n #variantHasSidebar(variant: SkenLayoutVariant): boolean {\n return variant === 'sidebar-content' || variant === 'header-sidebar' || variant === 'header-sidebar-footer'\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-layout': SkenLayout\n }\n}\n"],"mappings":";;;;;AAoDA,IAAM,IAAuD;CAC3D,IAAI;CACJ,IAAI;CACJ,IAAI;AACN,GAsBM,IAA4C;CAChD,QAAQ;CACR,eAAe;CACf,SAAS;CACT,SAAS;CACT,QAAQ;AACV,GAGa,IAAN,cAAyB,EAAW;;EAMiB,aALA,KAAA,UAAA,kBACgC,KAAA,aAAA,SAE7C,KAAA,kBAAA,SACgD,KAAA,cAAA,MACnC,KAAA,UAAA;;;EAE1C,KAAA,SAAA,CAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gEAoE2C,EAAU,EAAkB,MAAM,EAAE;;;;;;;;;UAS1F,EAAU,EAAkB,cAAc,EAAE;;;;;;;;;iEASW,EAAU,EAAkB,OAAO,EAAE;;;;;;;;iEAQrC,EAAU,EAAkB,OAAO,EAAE;;;;;;gEAMtC,EAAU,EAAkB,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qCA0G/D,EAAU,EAAc,EAAE,EAAE;;;qCAG5B,EAAU,EAAc,EAAE,EAAE;;;qCAG5B,EAAU,EAAc,EAAE,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiC/D,SAAkB;EAChB,IAAM,IAAa,KAAKA,GAAmB,KAAK,OAAO,GACjD,IAAc;GAClB;GACA,IAAa,iBAAiB;GAC9B,KAAc,KAAK,oBAAoB,QAAQ,gBAAgB;EACjE,CAAC,CACE,OAAO,OAAO,CAAC,CACf,KAAK,GAAG;EAEX,OAAO,CAAI;mBACI,EAAY;;;;CAI7B;CAOA,GAAmB,GAAqC;EACtD,OAAO,MAAY,qBAAqB,MAAY,oBAAoB,MAAY;CACtF;AACF;AArRG,EAAA,CAAA,EAAS,EAAE,SAAS,GAAK,CAAC,CAAA,GAAA,EAAA,WAAA,WAAA,KAAA,CAAA,GAC1B,EAAA,CAAA,EAAS;CAAE,WAAW;CAAe,SAAS;AAAK,CAAC,CAAA,GAAA,EAAA,WAAA,cAAA,KAAA,CAAA,GACpD,EAAA,CAAA,EAAS;CAAE,WAAW;CAAoB,SAAS;AAAK,CAAC,CAAA,GAAA,EAAA,WAAA,mBAAA,KAAA,CAAA,GAEzD,EAAA,CAAA,EAAS;CAAE,WAAW;CAAgB,SAAS;AAAK,CAAC,CAAA,GAAA,EAAA,WAAA,eAAA,KAAA,CAAA,GACrD,EAAA,CAAA,EAAS,EAAE,SAAS,GAAK,CAAC,CAAA,GAAA,EAAA,WAAA,WAAA,KAAA,CAAA,GAP5B,IAAA,EAAA,CAAA,EAAc,aAAa,CAAA,GAAA,CAAA"}
@@ -208,7 +208,9 @@ var s = 1, c = !0, l = class extends t {
208
208
  buttons from being squashed when the host's vertical
209
209
  rhythm is tight (sm). The explicit block-size wins. */
210
210
  flex-shrink: 0;
211
- transition: color 120ms ease, background-color 120ms ease;
211
+ transition:
212
+ color 120ms ease,
213
+ background-color 120ms ease;
212
214
  }
213
215
 
214
216
  .stepper:hover:not(:disabled) {
@@ -408,10 +410,10 @@ var s = 1, c = !0, l = class extends t {
408
410
  return r`
409
411
  <div class="field" part="field">
410
412
  ${this.label ? r`
411
- <label class="label" part="label" data-required=${this.required}>
412
- ${this.label}
413
- </label>
414
- ` : i}
413
+ <label class="label" part="label" data-required=${this.required}>
414
+ ${this.label}
415
+ </label>
416
+ ` : i}
415
417
  <div class="input-wrapper" part="wrapper">
416
418
  <div class="slot slot-start" part="slot-start">
417
419
  <slot name="start"></slot>
@@ -441,49 +443,37 @@ var s = 1, c = !0, l = class extends t {
441
443
  <slot name="end"></slot>
442
444
  </div>
443
445
  ${this.showStepperButtons ? r`
444
- <div class="steppers" part="steppers">
445
- <button
446
- class="stepper"
447
- part="stepper stepper-up"
448
- type="button"
449
- aria-label="Increment"
450
- ?disabled=${this.disabled || this.readonly || this.value !== void 0 && this.max !== void 0 && this.value >= this.max}
451
- @click=${this.#p}
452
- >
453
- +
454
- </button>
455
- <button
456
- class="stepper"
457
- part="stepper stepper-down"
458
- type="button"
459
- aria-label="Decrement"
460
- ?disabled=${this.disabled || this.readonly || this.value !== void 0 && this.min !== void 0 && this.value <= this.min}
461
- @click=${this.#m}
462
- >
463
-
464
- </button>
465
- </div>
466
- ` : i}
446
+ <div class="steppers" part="steppers">
447
+ <button
448
+ class="stepper"
449
+ part="stepper stepper-up"
450
+ type="button"
451
+ aria-label="Increment"
452
+ ?disabled=${this.disabled || this.readonly || this.value !== void 0 && this.max !== void 0 && this.value >= this.max}
453
+ @click=${this.#p}
454
+ >
455
+ +
456
+ </button>
457
+ <button
458
+ class="stepper"
459
+ part="stepper stepper-down"
460
+ type="button"
461
+ aria-label="Decrement"
462
+ ?disabled=${this.disabled || this.readonly || this.value !== void 0 && this.min !== void 0 && this.value <= this.min}
463
+ @click=${this.#m}
464
+ >
465
+
466
+ </button>
467
+ </div>
468
+ ` : i}
467
469
  </div>
468
- ${e.length > 0 ? r`
469
- <div class="messages" part="messages">${e}</div>
470
- ` : i}
470
+ ${e.length > 0 ? r` <div class="messages" part="messages">${e}</div> ` : i}
471
471
  </div>
472
472
  `;
473
473
  }
474
474
  #s() {
475
475
  let e = [];
476
- return this.invalidText && e.push(r`<p class="message" data-tone="invalid" part="message-invalid">
477
- ${this.invalidText}
478
- </p>`), this.warningText && e.push(r`<p class="message" data-tone="warning" part="message-warning">
479
- ${this.warningText}
480
- </p>`), this.infoText && e.push(r`<p class="message" data-tone="info" part="message-info">
481
- ${this.infoText}
482
- </p>`), this.validText && e.push(r`<p class="message" data-tone="valid" part="message-valid">
483
- ${this.validText}
484
- </p>`), this.helperText && e.length === 0 && e.push(r`<p class="message" data-tone="helper" part="message-helper">
485
- ${this.helperText}
486
- </p>`), e;
476
+ return this.invalidText && e.push(r`<p class="message" data-tone="invalid" part="message-invalid">${this.invalidText}</p>`), this.warningText && e.push(r`<p class="message" data-tone="warning" part="message-warning">${this.warningText}</p>`), this.infoText && e.push(r`<p class="message" data-tone="info" part="message-info">${this.infoText}</p>`), this.validText && e.push(r`<p class="message" data-tone="valid" part="message-valid">${this.validText}</p>`), this.helperText && e.length === 0 && e.push(r`<p class="message" data-tone="helper" part="message-helper">${this.helperText}</p>`), e;
487
477
  }
488
478
  #c;
489
479
  #l;
@@ -1 +1 @@
1
- {"version":3,"file":"sken-number-input.js","names":["#rawText","#parseNumber","#syncFormValue","#clamp","#step","#commitValue","#internals","#slotEndObserver","#visibilityObserver","#adjustLayout","#renderMessages","#handleInput","#handleChange","#handleFocus","#handleBlur","#handleKeydown","#handleStepUp","#handleStepDown"],"sources":["../src/components/sken-number-input.ts"],"sourcesContent":["// ── <sken-number-input> — Sken-owned Web Component ─────────────────\n// Lit 3 primitive for numeric input with visible ± stepper buttons,\n// min/max clamping, and the same start/end slot system as\n// <sken-input>. Promoted from the SkenNumberInput PoC story (which\n// was a thin wrap of <ix-number-input>). The PoC proved the UX;\n// this primitive owns the contract.\n\nimport { LitElement, html, css, nothing } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\nimport type { SkenNumberInputSize } from '@sken-ds/contracts'\n\n/**\n * Default step when the consumer does not pass one. Matches the\n * native `<input type=\"number\" step>` default.\n */\nconst DEFAULT_STEP = 1\n\n/**\n * Default value for `showStepperButtons`. The PoC validated that\n * the iX default of `false` is wrong for our use case: consumers\n * expect ± buttons for the numeric fields they use most (Alma\n * demo: quantity, temperature, score). The Sken primitive flips\n * the default to `true`; consumers who want a bare input pass\n * `showStepperButtons={false}` explicitly.\n */\nconst DEFAULT_SHOW_STEPPER_BUTTONS = true\n\n@customElement('sken-number-input')\nexport class SkenNumberInput extends LitElement {\n // Form-associated custom element. Same pattern as SkenInput /\n // SkenTextarea. The numeric value is stringified for\n // FormData (FormData is text-only); undefined becomes the\n // empty string. The implicit-submit-on-Enter rule still\n // applies, so a form with a single number input and a\n // submit button will submit on Enter.\n // https://lit.dev/docs/components/form/\n static formAssociated = true\n\n // ElementInternals handle. Created in the constructor.\n // Nullable to gracefully degrade in test environments.\n #internals: ElementInternals | null = null\n\n constructor() {\n super()\n try {\n this.#internals = this.attachInternals()\n } catch {\n this.#internals = null\n }\n }\n\n // ── Props ─────────────────────────────────────────────────────\n @property({ attribute: 'default-value', type: Number }) defaultValue: number | undefined = undefined\n @property({ type: Number }) value: number | undefined = undefined\n @property({ type: Number }) min: number | undefined = undefined\n @property({ type: Number }) max: number | undefined = undefined\n @property({ type: Number }) step: number = DEFAULT_STEP\n @property({ attribute: 'show-stepper-buttons', reflect: true, type: Boolean }) showStepperButtons =\n DEFAULT_SHOW_STEPPER_BUTTONS\n @property() placeholder: string | undefined = undefined\n @property() label: string | undefined = undefined\n @property({ reflect: true, type: Boolean }) required = false\n @property({ reflect: true, type: Boolean }) disabled = false\n @property({ reflect: true, type: Boolean }) readonly = false\n @property({ attribute: 'helper-text' }) helperText: string | undefined = undefined\n @property({ attribute: 'info-text' }) infoText: string | undefined = undefined\n @property({ attribute: 'warning-text' }) warningText: string | undefined = undefined\n @property({ attribute: 'valid-text' }) validText: string | undefined = undefined\n @property({ attribute: 'invalid-text' }) invalidText: string | undefined = undefined\n @property({ attribute: 'text-alignment' }) textAlignment: 'start' | 'end' = 'end'\n @property({ reflect: true }) size: SkenNumberInputSize = 'md'\n @property({ reflect: true, type: Boolean }) invalid = false\n @property({ attribute: 'described-by-id' }) describedById: string | undefined = undefined\n @property() name: string | undefined = undefined\n\n // ── Internal state ────────────────────────────────────────────\n /**\n * The raw text in the input. The DOM input is the source of\n * truth for the text (so the user can type a partial value like\n * \"-\", which is not a valid number yet). The numeric `value` is\n * derived from this on commit (blur, Enter, stepper).\n */\n #rawText: string = ''\n\n // ── DOM refs (queried on demand) ────────────────────────────\n\n // ── Lifecycle ─────────────────────────────────────────────────\n override connectedCallback(): void {\n super.connectedCallback()\n // Seed the raw text from the controlled value or the default.\n // We do NOT trigger a re-render here; the property is only\n // set after Lit has applied the host attributes, and the\n // first render will read the right value through this.value.\n if (this.value === undefined) {\n this.#rawText = this.defaultValue !== undefined ? String(this.defaultValue) : ''\n } else {\n this.#rawText = String(this.value)\n }\n // Publish the initial value to the surrounding form.\n this.#syncFormValue()\n }\n\n override disconnectedCallback(): void {\n super.disconnectedCallback()\n this.#slotEndObserver?.disconnect()\n this.#visibilityObserver?.disconnect()\n }\n\n // ── Form-association callbacks ─────────────────────────────\n // formResetCallback: restore uncontrolled seed; re-render\n // controlled so Lit re-publishes the controlled value.\n formResetCallback(): void {\n if (this.value === undefined) {\n this.#rawText = this.defaultValue !== undefined ? String(this.defaultValue) : ''\n const inputEl = this.renderRoot.querySelector('input') as HTMLInputElement | null\n if (inputEl) inputEl.value = this.#rawText\n this.#syncFormValue()\n } else {\n this.requestUpdate()\n }\n }\n\n // formDisabledCallback: mirror the form's :disabled state\n // onto the prop. Lit re-renders; the inner <input> picks up\n // ?disabled=${this.disabled} in the template.\n formDisabledCallback(isDisabled: boolean): void {\n this.disabled = isDisabled\n }\n\n // Publish the current value to the form. FormData is\n // text-only, so we stringify the number (or empty string\n // for undefined). The string roundtrip preserves precision\n // for integers and finite decimals; consumers that need\n // BigInt or arbitrary precision should re-parse on the\n // server side.\n #syncFormValue(): void {\n if (!this.#internals) return\n const value = this.value !== undefined ? String(this.value) : ''\n this.#internals.setFormValue(value)\n }\n\n /**\n * Set up the dynamic layout once the shadow root has the\n * element children we need to measure. Same pattern as\n * SkenInput: MutationObserver on the slot container to\n * catch children being added/removed, IntersectionObserver\n * to catch the case where the input is hidden at first\n * connect (e.g. inside a closed <details> or an off-screen\n * tab), and slotchange as belt-and-braces.\n */\n protected override firstUpdated(): void {\n const endContainer = this.renderRoot.querySelector('.slot-end') as HTMLElement | null\n const endSlot = this.renderRoot.querySelector('slot[name=\"end\"]') as HTMLSlotElement | null\n if (endContainer && endSlot) {\n this.#slotEndObserver = new MutationObserver(() => this.#adjustLayout())\n this.#slotEndObserver.observe(endContainer, { childList: true, subtree: true, attributes: true })\n endSlot.addEventListener('slotchange', this.#adjustLayout)\n }\n this.#visibilityObserver = new IntersectionObserver(entries => {\n for (const entry of entries) {\n if (entry.isIntersecting) this.#adjustLayout()\n }\n })\n this.#visibilityObserver.observe(this)\n this.#adjustLayout()\n }\n\n /**\n * The gap between a slot's edge and the input's text. 0.5rem\n * is what the SkenInput primitive uses (mirrors the iX rule).\n * The number input needs an extra \"air\" margin because the\n * stepper column is also on the right edge and we don't want\n * the value text to crash into the slot.\n */\n #SLOT_AIR = '0.5rem'\n\n /**\n * Measure the stepper column width and the end slot width, then\n * apply:\n * - The input's padding-inline-end so the value text never\n * overlaps the slot OR the stepper.\n * - The end slot's inset-inline-end so it sits to the LEFT of\n * the stepper (or at the right edge when no stepper).\n *\n * The math:\n * endInset = stepperReserve + air (slot's right edge)\n * paddingInline = max(endInset, stepperReserve) + air\n * If the end slot is empty, its width is 0 and the calc\n * falls back to the stepper-only reservation.\n */\n #adjustLayout = (): void => {\n const inputEl = this.renderRoot.querySelector('input') as HTMLInputElement | null\n const stepperEl = this.renderRoot.querySelector('.steppers') as HTMLElement | null\n const slotEndEl = this.renderRoot.querySelector('.slot-end') as HTMLElement | null\n if (!inputEl || !slotEndEl) return\n\n requestAnimationFrame(() => {\n if (!inputEl.isConnected) return\n const stepperWidth = stepperEl && this.showStepperButtons\n ? stepperEl.getBoundingClientRect().width\n : 0\n const endWidth = slotEndEl.getBoundingClientRect().width\n const air = parseFloat(getComputedStyle(inputEl).fontSize) * 0.5 // 0.5em in px\n const stepperReserve = stepperWidth\n // The slot's right edge sits at stepperReserve + air from\n // the input's right edge, so it never overlaps the stepper\n // column.\n const endInset = stepperReserve + air\n slotEndEl.style.insetInlineEnd = endInset > 0 ? `${endInset}px` : '0.5rem'\n // The input's padding-inline-end has to be at least the\n // wider of (stepperReserve, endInset + endWidth) so the\n // value text never overlaps either.\n const textReserve = endInset + endWidth\n const padEnd = Math.max(stepperReserve, textReserve) + air\n inputEl.style.paddingInlineEnd = `${padEnd}px`\n })\n }\n\n #slotEndObserver: MutationObserver | null = null\n #visibilityObserver: IntersectionObserver | null = null\n\n // ── Styles ────────────────────────────────────────────────────\n static styles = css`\n :host {\n display: inline-block;\n inline-size: 100%;\n }\n\n .field {\n display: grid;\n gap: 0.375rem;\n }\n\n .label {\n font-size: 0.8125rem;\n font-weight: 500;\n color: var(--sken-foreground);\n }\n\n .label[data-required='true']::after {\n content: ' *';\n color: var(--sken-destructive);\n }\n\n .input-wrapper {\n position: relative;\n display: flex;\n align-items: center;\n inline-size: 100%;\n }\n\n input {\n box-sizing: border-box;\n inline-size: 100%;\n border: 1px solid var(--sken-border);\n border-radius: var(--sken-md);\n background: var(--sken-input);\n color: var(--sken-foreground);\n font-family: var(--sken-family-sans);\n font-size: var(--sken-size-md);\n line-height: 1.25;\n text-overflow: ellipsis;\n padding-block: var(--sken-2);\n padding-inline: var(--sken-3);\n /* When the ± steppers are visible, reserve the right edge\n for them so the value text never overlaps. 1.5rem (button)\n + 2 * 0.25rem (insets) + 0.25rem (air) = 2.25rem. */\n padding-inline-end: var(--sken-3);\n min-block-size: 2.25rem;\n transition:\n border-color 120ms ease,\n box-shadow 120ms ease;\n /* Hide the native steppers: we render our own. */\n -moz-appearance: textfield;\n }\n\n input::-webkit-outer-spin-button,\n input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n input::placeholder {\n color: var(--sken-muted-foreground);\n opacity: 1;\n }\n\n /* ── Stepper buttons ───────────────────────────────────── */\n /*\n * The stepper column is a vertical pair of + and − buttons\n * anchored to the right edge of the input. Sizes scale with\n * the host's [size] attribute (sm / md / lg) so the buttons\n * match the input's vertical rhythm. Default (md) is 24x20\n * per button. Glyph color is --sken-foreground; hover swaps\n * to --sken-primary. Disabled state uses --sken-disabled.\n */\n .steppers {\n position: absolute;\n inset-block: 0.25rem;\n inset-inline-end: 0.25rem;\n display: flex;\n flex-direction: column;\n gap: 2px;\n align-items: center;\n justify-content: center;\n }\n\n .stepper {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n inline-size: 1.5rem;\n block-size: 1.25rem;\n background: transparent;\n border: 0;\n padding: 0;\n color: var(--sken-foreground);\n cursor: pointer;\n font-family: inherit;\n font-size: 1rem;\n line-height: 1;\n font-weight: 600;\n border-radius: var(--sken-sm, 0.25rem);\n /* The parent .steppers is a flex column; prevent the\n buttons from being squashed when the host's vertical\n rhythm is tight (sm). The explicit block-size wins. */\n flex-shrink: 0;\n transition: color 120ms ease, background-color 120ms ease;\n }\n\n .stepper:hover:not(:disabled) {\n color: var(--sken-primary);\n /* No background fill on hover. A background would paint\n over the input's right border, hiding it. The color\n change to --sken-primary is enough affordance for a\n 20x24px icon button. */\n background: transparent;\n }\n\n .stepper:focus-visible {\n outline: 2px solid var(--sken-ring, currentColor);\n outline-offset: 1px;\n }\n\n .stepper:disabled {\n cursor: not-allowed;\n opacity: var(--sken-disabled);\n }\n\n /* When the ± steppers are visible, reserve the right edge\n of the input so the value text never overlaps. The\n reservation is 2rem for the md size (default): 1.5rem\n (button) + 2 * 0.25rem (column insets). The sm / lg\n variants below override the button size, so the\n reservation needs to scale too. */\n :host([show-stepper-buttons]) input {\n padding-inline-end: 2rem;\n }\n\n /* ── Stepper sizes ─────────────────────────────────────── */\n :host([size='sm']) .steppers {\n inset-block: 0.125rem;\n inset-inline-end: 0.125rem;\n gap: 1px;\n }\n :host([size='sm']) .stepper {\n inline-size: 1.125rem;\n block-size: 0.875rem;\n font-size: 0.75rem;\n }\n /* sm: 1.125rem (button) + 2 * 0.125rem (insets) = 1.375rem,\n plus 0.125rem of breathing air = 1.5rem. */\n :host([size='sm'][show-stepper-buttons]) input {\n padding-inline-end: 1.5rem;\n }\n\n :host([size='lg']) .steppers {\n inset-block: 0.375rem;\n inset-inline-end: 0.375rem;\n gap: 3px;\n }\n :host([size='lg']) .stepper {\n inline-size: 1.75rem;\n block-size: 1.5rem;\n font-size: 1.125rem;\n }\n /* lg: 1.75rem (button) + 2 * 0.375rem (insets) = 2.5rem,\n plus 0.25rem of breathing air = 2.75rem. */\n :host([size='lg'][show-stepper-buttons]) input {\n padding-inline-end: 2.75rem;\n }\n\n /* Hide steppers on touch devices where they would be hard\n to hit accurately. The user can still use ↑/↓ keys or\n type directly. */\n @media (hover: none) {\n .steppers {\n display: none;\n }\n input {\n padding-inline-end: var(--sken-3);\n }\n :host([size='sm']) input {\n padding-inline-end: var(--sken-2);\n }\n :host([size='lg']) input {\n padding-inline-end: var(--sken-4);\n }\n }\n\n /* ── Slot containers ────────────────────────────────────── */\n .slot {\n position: absolute;\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1;\n pointer-events: none;\n color: var(--sken-muted-foreground);\n }\n\n .slot ::slotted(*) {\n pointer-events: auto;\n }\n\n .slot-start {\n inset-inline-start: 0.5rem;\n }\n\n /*\n * The end slot's position is set in JS via the inline style\n * (see #adjustLayout). The rule below is a fallback for the\n * initial render before JS has measured the slot, and a\n * sensible default when the slot is empty.\n */\n .slot-end {\n inset-inline-end: 0.5rem;\n }\n\n /* ── States ─────────────────────────────────────────────── */\n .input-wrapper:hover input:not(:disabled):not(:read-only) {\n border-color: var(--sken-foreground);\n }\n\n .input-wrapper:focus-within input {\n outline: 2px solid var(--sken-ring, currentColor);\n outline-offset: 2px;\n border-color: var(--sken-primary);\n }\n\n input:disabled {\n cursor: not-allowed;\n opacity: var(--sken-disabled);\n }\n\n input:read-only {\n background: var(--sken-muted);\n }\n\n :host([invalid]) input {\n border-color: var(--sken-destructive);\n }\n\n :host([invalid]) .input-wrapper:focus-within input {\n outline-color: var(--sken-destructive);\n }\n\n /* ── Sizes ──────────────────────────────────────────────── */\n :host([size='sm']) input {\n padding-block: var(--sken-1);\n padding-inline: var(--sken-2);\n font-size: var(--sken-size-sm);\n /* 2 × 0.875rem (buttons) + 1px (gap) + 2 × 0.125rem\n (insets) = ~2.0625rem. Round up to 2.25rem for visual\n breathing room. */\n min-block-size: 2.25rem;\n }\n :host([size='md']) input {\n /* Default styles above. */\n }\n :host([size='lg']) input {\n padding-block: var(--sken-3);\n padding-inline: var(--sken-4);\n font-size: var(--sken-size-lg);\n /* 2 × 1.5rem (buttons) + 3px (gap) + 2 × 0.375rem\n (insets) = ~3.9375rem. Round up to 4rem. */\n min-block-size: 4rem;\n }\n\n /* ── Validation messages ─────────────────────────────────── */\n .messages {\n display: grid;\n gap: 0.25rem;\n }\n\n .message {\n margin: 0;\n font-size: 0.75rem;\n line-height: 1.4;\n }\n\n .message[data-tone='info'] {\n color: var(--sken-info, #0082ff);\n }\n\n .message[data-tone='warning'] {\n color: var(--sken-warning, #f59e0b);\n }\n\n .message[data-tone='valid'] {\n color: var(--sken-success, #10b981);\n }\n\n .message[data-tone='invalid'] {\n color: var(--sken-destructive, #dc2626);\n }\n\n .message[data-tone='helper'] {\n color: var(--sken-muted-foreground);\n }\n `\n\n // ── Render ────────────────────────────────────────────────────\n protected override render() {\n const messages = this.#renderMessages()\n return html`\n <div class=\"field\" part=\"field\">\n ${this.label\n ? html`\n <label class=\"label\" part=\"label\" data-required=${this.required}>\n ${this.label}\n </label>\n `\n : nothing}\n <div class=\"input-wrapper\" part=\"wrapper\">\n <div class=\"slot slot-start\" part=\"slot-start\">\n <slot name=\"start\"></slot>\n </div>\n <input\n part=\"input\"\n type=\"number\"\n .value=${this.#rawText}\n placeholder=${this.placeholder ?? ''}\n ?disabled=${this.disabled}\n ?readonly=${this.readonly}\n ?required=${this.required}\n min=${this.min ?? ''}\n max=${this.max ?? ''}\n step=${this.step}\n aria-invalid=${this.invalid ? 'true' : 'false'}\n aria-describedby=${this.describedById ?? ''}\n name=${this.name ?? ''}\n style=\"text-align: ${this.textAlignment}\"\n @input=${this.#handleInput}\n @change=${this.#handleChange}\n @focus=${this.#handleFocus}\n @blur=${this.#handleBlur}\n @keydown=${this.#handleKeydown}\n />\n <div class=\"slot slot-end\" part=\"slot-end\">\n <slot name=\"end\"></slot>\n </div>\n ${this.showStepperButtons\n ? html`\n <div class=\"steppers\" part=\"steppers\">\n <button\n class=\"stepper\"\n part=\"stepper stepper-up\"\n type=\"button\"\n aria-label=\"Increment\"\n ?disabled=${this.disabled || this.readonly ||\n (this.value !== undefined && this.max !== undefined && this.value >= this.max)}\n @click=${this.#handleStepUp}\n >\n +\n </button>\n <button\n class=\"stepper\"\n part=\"stepper stepper-down\"\n type=\"button\"\n aria-label=\"Decrement\"\n ?disabled=${this.disabled || this.readonly ||\n (this.value !== undefined && this.min !== undefined && this.value <= this.min)}\n @click=${this.#handleStepDown}\n >\n −\n </button>\n </div>\n `\n : nothing}\n </div>\n ${messages.length > 0\n ? html`\n <div class=\"messages\" part=\"messages\">${messages}</div>\n `\n : nothing}\n </div>\n `\n }\n\n #renderMessages() {\n const messages: ReturnType<typeof html>[] = []\n if (this.invalidText) {\n messages.push(\n html`<p class=\"message\" data-tone=\"invalid\" part=\"message-invalid\">\n ${this.invalidText}\n </p>`,\n )\n }\n if (this.warningText) {\n messages.push(\n html`<p class=\"message\" data-tone=\"warning\" part=\"message-warning\">\n ${this.warningText}\n </p>`,\n )\n }\n if (this.infoText) {\n messages.push(\n html`<p class=\"message\" data-tone=\"info\" part=\"message-info\">\n ${this.infoText}\n </p>`,\n )\n }\n if (this.validText) {\n messages.push(\n html`<p class=\"message\" data-tone=\"valid\" part=\"message-valid\">\n ${this.validText}\n </p>`,\n )\n }\n if (this.helperText && messages.length === 0) {\n messages.push(\n html`<p class=\"message\" data-tone=\"helper\" part=\"message-helper\">\n ${this.helperText}\n </p>`,\n )\n }\n return messages\n }\n\n // ── Event handlers ───────────────────────────────────────────\n #handleInput = (event: Event) => {\n const target = event.target as HTMLInputElement\n this.#rawText = target.value\n // Emit the parsed number (or undefined if empty / invalid).\n const parsed = this.#parseNumber(target.value)\n if (this.value === undefined) {\n // Uncontrolled: store internally, do not mutate this.value\n // because the contract is \"controlled by the consumer\".\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-input', {\n detail: parsed,\n bubbles: true,\n composed: true,\n }),\n )\n } else {\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-input', {\n detail: parsed,\n bubbles: true,\n composed: true,\n }),\n )\n }\n }\n\n #handleChange = (event: Event) => {\n const target = event.target as HTMLInputElement\n const parsed = this.#parseNumber(target.value)\n // Clamp on commit. If the user typed 150 with max=100, we\n // commit 100 and update the input text.\n const clamped = this.#clamp(parsed)\n if (clamped !== parsed) {\n this.#rawText = clamped !== undefined ? String(clamped) : ''\n target.value = this.#rawText\n }\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-change', {\n detail: clamped,\n bubbles: true,\n composed: true,\n }),\n )\n }\n\n #handleFocus = (_event: FocusEvent) => {\n this.dispatchEvent(new CustomEvent('sken-focus', { bubbles: true, composed: true }))\n }\n\n #handleBlur = (_event: FocusEvent) => {\n this.dispatchEvent(new CustomEvent('sken-blur', { bubbles: true, composed: true }))\n }\n\n #handleKeydown = (event: KeyboardEvent) => {\n if (this.disabled || this.readonly) return\n // Shift+Arrow and PageUp/PageDown apply a 10x multiplier to\n // the step. This is the standard \"fast forward\" pattern in\n // numeric inputs across Mature DS (iX, Material, AntD). It\n // lets the consumer reach the target faster when the step\n // is small relative to the typical range.\n const multiplier = event.shiftKey || event.key === 'PageUp' || event.key === 'PageDown' ? 10 : 1\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n this.#step(+1, multiplier)\n } else if (event.key === 'ArrowDown') {\n event.preventDefault()\n this.#step(-1, multiplier)\n } else if (event.key === 'PageUp') {\n event.preventDefault()\n this.#step(+1, 10)\n } else if (event.key === 'PageDown') {\n event.preventDefault()\n this.#step(-1, 10)\n } else if (event.key === 'Home' && this.min !== undefined) {\n event.preventDefault()\n this.#commitValue(this.min)\n } else if (event.key === 'End' && this.max !== undefined) {\n event.preventDefault()\n this.#commitValue(this.max)\n } else if (event.key === 'Enter') {\n // The browser fires a `change` event on Enter for\n // <input type=\"number\">, so we don't need to commit\n // anything manually. We DO need to bridge the shadow\n // boundary so the surrounding <form> receives a submit\n // event — the browser's implicit submission algorithm\n // does not see the inner input. Mirrors SkenInput and\n // SkenTextarea. `isComposing` guards IME composition.\n if (event.isComposing) return\n // preventDefault runs even without ElementInternals,\n // keeping the observable behaviour consistent.\n event.preventDefault()\n const form = this.#internals?.form\n if (!form) return\n form.requestSubmit()\n }\n }\n\n #handleStepUp = () => this.#step(+1)\n #handleStepDown = () => this.#step(-1)\n\n // ── Helpers ───────────────────────────────────────────────────\n /**\n * Increment / decrement the value by `step * multiplier`,\n * clamped at [min, max]. Emits `sken-input` and `sken-change`\n * (we treat the stepper as a commit, not a keystroke). The\n * `multiplier` defaults to 1; the keyboard handler passes 10\n * for Shift+Arrow and PageUp/PageDown (the \"fast forward\"\n * pattern).\n *\n * The current value is read from the rendered <input>, NOT\n * from `this.value`. Reading from the input is robust for\n * both modes:\n * - Controlled: the consumer is async (Vue's reactive update\n * applies on the next tick). By the time the second click\n * arrives, the consumer's `value` prop may still be the\n * pre-click value, but the input's `.value` is already\n * updated by #commitValue from the first click.\n * - Uncontrolled: the primitive owns the value. The input's\n * `.value` is the source of truth; `this.value` is the\n * initial seed and not maintained by the primitive in this\n * mode.\n */\n #step(direction: 1 | -1, multiplier: number = 1) {\n if (this.disabled || this.readonly) return\n const inputEl = this.renderRoot.querySelector('input') as HTMLInputElement | null\n const text = inputEl?.value ?? ''\n const current = text === '' ? (this.value ?? 0) : Number(text)\n if (!Number.isFinite(current)) return\n const next = current + direction * this.step * multiplier\n this.#commitValue(next)\n }\n\n /**\n * Commit a value: clamp, update internal state, emit events.\n * Same path used by the stepper buttons and the keyboard\n * Home / End shortcuts.\n */\n #commitValue(raw: number) {\n const clamped = this.#clamp(raw)\n this.#rawText = clamped !== undefined ? String(clamped) : ''\n // Update the DOM input so the next @input sees the new value\n const inputEl = this.renderRoot.querySelector('input') as HTMLInputElement | null\n if (inputEl) inputEl.value = this.#rawText\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-input', {\n detail: clamped,\n bubbles: true,\n composed: true,\n }),\n )\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-change', {\n detail: clamped,\n bubbles: true,\n composed: true,\n }),\n )\n }\n\n #clamp(value: number | undefined): number | undefined {\n if (value === undefined || Number.isNaN(value)) return undefined\n let v = value\n if (this.min !== undefined && v < this.min) v = this.min\n if (this.max !== undefined && v > this.max) v = this.max\n return v\n }\n\n #parseNumber(text: string): number | undefined {\n if (text === '' || text === '-') return undefined\n const n = Number(text)\n if (Number.isNaN(n)) return undefined\n return n\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-number-input': SkenNumberInput\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sken-number-input.js","names":["#rawText","#parseNumber","#syncFormValue","#clamp","#step","#commitValue","#internals","#slotEndObserver","#visibilityObserver","#adjustLayout","#renderMessages","#handleInput","#handleChange","#handleFocus","#handleBlur","#handleKeydown","#handleStepUp","#handleStepDown"],"sources":["../src/components/sken-number-input.ts"],"sourcesContent":["// ── <sken-number-input> — Sken-owned Web Component ─────────────────\n// Lit 3 primitive for numeric input with visible ± stepper buttons,\n// min/max clamping, and the same start/end slot system as\n// <sken-input>. Promoted from the SkenNumberInput PoC story (which\n// was a thin wrap of <ix-number-input>). The PoC proved the UX;\n// this primitive owns the contract.\n\nimport type { SkenNumberInputSize } from '@sken-ds/contracts'\nimport { LitElement, css, html, nothing } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\n/**\n * Default step when the consumer does not pass one. Matches the\n * native `<input type=\"number\" step>` default.\n */\nconst DEFAULT_STEP = 1\n\n/**\n * Default value for `showStepperButtons`. The PoC validated that\n * the iX default of `false` is wrong for our use case: consumers\n * expect ± buttons for the numeric fields they use most (Alma\n * demo: quantity, temperature, score). The Sken primitive flips\n * the default to `true`; consumers who want a bare input pass\n * `showStepperButtons={false}` explicitly.\n */\nconst DEFAULT_SHOW_STEPPER_BUTTONS = true\n\n@customElement('sken-number-input')\nexport class SkenNumberInput extends LitElement {\n // Form-associated custom element. Same pattern as SkenInput /\n // SkenTextarea. The numeric value is stringified for\n // FormData (FormData is text-only); undefined becomes the\n // empty string. The implicit-submit-on-Enter rule still\n // applies, so a form with a single number input and a\n // submit button will submit on Enter.\n // https://lit.dev/docs/components/form/\n static formAssociated = true\n\n // ElementInternals handle. Created in the constructor.\n // Nullable to gracefully degrade in test environments.\n #internals: ElementInternals | null = null\n\n constructor() {\n super()\n try {\n this.#internals = this.attachInternals()\n } catch {\n this.#internals = null\n }\n }\n\n // ── Props ─────────────────────────────────────────────────────\n @property({ attribute: 'default-value', type: Number }) defaultValue: number | undefined =\n undefined\n @property({ type: Number }) value: number | undefined = undefined\n @property({ type: Number }) min: number | undefined = undefined\n @property({ type: Number }) max: number | undefined = undefined\n @property({ type: Number }) step: number = DEFAULT_STEP\n @property({ attribute: 'show-stepper-buttons', reflect: true, type: Boolean })\n showStepperButtons = DEFAULT_SHOW_STEPPER_BUTTONS\n @property() placeholder: string | undefined = undefined\n @property() label: string | undefined = undefined\n @property({ reflect: true, type: Boolean }) required = false\n @property({ reflect: true, type: Boolean }) disabled = false\n @property({ reflect: true, type: Boolean }) readonly = false\n @property({ attribute: 'helper-text' }) helperText: string | undefined = undefined\n @property({ attribute: 'info-text' }) infoText: string | undefined = undefined\n @property({ attribute: 'warning-text' }) warningText: string | undefined = undefined\n @property({ attribute: 'valid-text' }) validText: string | undefined = undefined\n @property({ attribute: 'invalid-text' }) invalidText: string | undefined = undefined\n @property({ attribute: 'text-alignment' }) textAlignment: 'start' | 'end' = 'end'\n @property({ reflect: true }) size: SkenNumberInputSize = 'md'\n @property({ reflect: true, type: Boolean }) invalid = false\n @property({ attribute: 'described-by-id' }) describedById: string | undefined = undefined\n @property() name: string | undefined = undefined\n\n // ── Internal state ────────────────────────────────────────────\n /**\n * The raw text in the input. The DOM input is the source of\n * truth for the text (so the user can type a partial value like\n * \"-\", which is not a valid number yet). The numeric `value` is\n * derived from this on commit (blur, Enter, stepper).\n */\n #rawText: string = ''\n\n // ── DOM refs (queried on demand) ────────────────────────────\n\n // ── Lifecycle ─────────────────────────────────────────────────\n override connectedCallback(): void {\n super.connectedCallback()\n // Seed the raw text from the controlled value or the default.\n // We do NOT trigger a re-render here; the property is only\n // set after Lit has applied the host attributes, and the\n // first render will read the right value through this.value.\n if (this.value === undefined) {\n this.#rawText = this.defaultValue !== undefined ? String(this.defaultValue) : ''\n } else {\n this.#rawText = String(this.value)\n }\n // Publish the initial value to the surrounding form.\n this.#syncFormValue()\n }\n\n override disconnectedCallback(): void {\n super.disconnectedCallback()\n this.#slotEndObserver?.disconnect()\n this.#visibilityObserver?.disconnect()\n }\n\n // ── Form-association callbacks ─────────────────────────────\n // formResetCallback: restore uncontrolled seed; re-render\n // controlled so Lit re-publishes the controlled value.\n formResetCallback(): void {\n if (this.value === undefined) {\n this.#rawText = this.defaultValue !== undefined ? String(this.defaultValue) : ''\n const inputEl = this.renderRoot.querySelector('input') as HTMLInputElement | null\n if (inputEl) inputEl.value = this.#rawText\n this.#syncFormValue()\n } else {\n this.requestUpdate()\n }\n }\n\n // formDisabledCallback: mirror the form's :disabled state\n // onto the prop. Lit re-renders; the inner <input> picks up\n // ?disabled=${this.disabled} in the template.\n formDisabledCallback(isDisabled: boolean): void {\n this.disabled = isDisabled\n }\n\n // Publish the current value to the form. FormData is\n // text-only, so we stringify the number (or empty string\n // for undefined). The string roundtrip preserves precision\n // for integers and finite decimals; consumers that need\n // BigInt or arbitrary precision should re-parse on the\n // server side.\n #syncFormValue(): void {\n if (!this.#internals) return\n const value = this.value !== undefined ? String(this.value) : ''\n this.#internals.setFormValue(value)\n }\n\n /**\n * Set up the dynamic layout once the shadow root has the\n * element children we need to measure. Same pattern as\n * SkenInput: MutationObserver on the slot container to\n * catch children being added/removed, IntersectionObserver\n * to catch the case where the input is hidden at first\n * connect (e.g. inside a closed <details> or an off-screen\n * tab), and slotchange as belt-and-braces.\n */\n protected override firstUpdated(): void {\n const endContainer = this.renderRoot.querySelector('.slot-end') as HTMLElement | null\n const endSlot = this.renderRoot.querySelector('slot[name=\"end\"]') as HTMLSlotElement | null\n if (endContainer && endSlot) {\n this.#slotEndObserver = new MutationObserver(() => this.#adjustLayout())\n this.#slotEndObserver.observe(endContainer, {\n childList: true,\n subtree: true,\n attributes: true,\n })\n endSlot.addEventListener('slotchange', this.#adjustLayout)\n }\n this.#visibilityObserver = new IntersectionObserver((entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) this.#adjustLayout()\n }\n })\n this.#visibilityObserver.observe(this)\n this.#adjustLayout()\n }\n\n /**\n * The gap between a slot's edge and the input's text. 0.5rem\n * is what the SkenInput primitive uses (mirrors the iX rule).\n * The number input needs an extra \"air\" margin because the\n * stepper column is also on the right edge and we don't want\n * the value text to crash into the slot.\n */\n #SLOT_AIR = '0.5rem'\n\n /**\n * Measure the stepper column width and the end slot width, then\n * apply:\n * - The input's padding-inline-end so the value text never\n * overlaps the slot OR the stepper.\n * - The end slot's inset-inline-end so it sits to the LEFT of\n * the stepper (or at the right edge when no stepper).\n *\n * The math:\n * endInset = stepperReserve + air (slot's right edge)\n * paddingInline = max(endInset, stepperReserve) + air\n * If the end slot is empty, its width is 0 and the calc\n * falls back to the stepper-only reservation.\n */\n #adjustLayout = (): void => {\n const inputEl = this.renderRoot.querySelector('input') as HTMLInputElement | null\n const stepperEl = this.renderRoot.querySelector('.steppers') as HTMLElement | null\n const slotEndEl = this.renderRoot.querySelector('.slot-end') as HTMLElement | null\n if (!inputEl || !slotEndEl) return\n\n requestAnimationFrame(() => {\n if (!inputEl.isConnected) return\n const stepperWidth =\n stepperEl && this.showStepperButtons ? stepperEl.getBoundingClientRect().width : 0\n const endWidth = slotEndEl.getBoundingClientRect().width\n const air = parseFloat(getComputedStyle(inputEl).fontSize) * 0.5 // 0.5em in px\n const stepperReserve = stepperWidth\n // The slot's right edge sits at stepperReserve + air from\n // the input's right edge, so it never overlaps the stepper\n // column.\n const endInset = stepperReserve + air\n slotEndEl.style.insetInlineEnd = endInset > 0 ? `${endInset}px` : '0.5rem'\n // The input's padding-inline-end has to be at least the\n // wider of (stepperReserve, endInset + endWidth) so the\n // value text never overlaps either.\n const textReserve = endInset + endWidth\n const padEnd = Math.max(stepperReserve, textReserve) + air\n inputEl.style.paddingInlineEnd = `${padEnd}px`\n })\n }\n\n #slotEndObserver: MutationObserver | null = null\n #visibilityObserver: IntersectionObserver | null = null\n\n // ── Styles ────────────────────────────────────────────────────\n static styles = css`\n :host {\n display: inline-block;\n inline-size: 100%;\n }\n\n .field {\n display: grid;\n gap: 0.375rem;\n }\n\n .label {\n font-size: 0.8125rem;\n font-weight: 500;\n color: var(--sken-foreground);\n }\n\n .label[data-required='true']::after {\n content: ' *';\n color: var(--sken-destructive);\n }\n\n .input-wrapper {\n position: relative;\n display: flex;\n align-items: center;\n inline-size: 100%;\n }\n\n input {\n box-sizing: border-box;\n inline-size: 100%;\n border: 1px solid var(--sken-border);\n border-radius: var(--sken-md);\n background: var(--sken-input);\n color: var(--sken-foreground);\n font-family: var(--sken-family-sans);\n font-size: var(--sken-size-md);\n line-height: 1.25;\n text-overflow: ellipsis;\n padding-block: var(--sken-2);\n padding-inline: var(--sken-3);\n /* When the ± steppers are visible, reserve the right edge\n for them so the value text never overlaps. 1.5rem (button)\n + 2 * 0.25rem (insets) + 0.25rem (air) = 2.25rem. */\n padding-inline-end: var(--sken-3);\n min-block-size: 2.25rem;\n transition:\n border-color 120ms ease,\n box-shadow 120ms ease;\n /* Hide the native steppers: we render our own. */\n -moz-appearance: textfield;\n }\n\n input::-webkit-outer-spin-button,\n input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n input::placeholder {\n color: var(--sken-muted-foreground);\n opacity: 1;\n }\n\n /* ── Stepper buttons ───────────────────────────────────── */\n /*\n * The stepper column is a vertical pair of + and − buttons\n * anchored to the right edge of the input. Sizes scale with\n * the host's [size] attribute (sm / md / lg) so the buttons\n * match the input's vertical rhythm. Default (md) is 24x20\n * per button. Glyph color is --sken-foreground; hover swaps\n * to --sken-primary. Disabled state uses --sken-disabled.\n */\n .steppers {\n position: absolute;\n inset-block: 0.25rem;\n inset-inline-end: 0.25rem;\n display: flex;\n flex-direction: column;\n gap: 2px;\n align-items: center;\n justify-content: center;\n }\n\n .stepper {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n inline-size: 1.5rem;\n block-size: 1.25rem;\n background: transparent;\n border: 0;\n padding: 0;\n color: var(--sken-foreground);\n cursor: pointer;\n font-family: inherit;\n font-size: 1rem;\n line-height: 1;\n font-weight: 600;\n border-radius: var(--sken-sm, 0.25rem);\n /* The parent .steppers is a flex column; prevent the\n buttons from being squashed when the host's vertical\n rhythm is tight (sm). The explicit block-size wins. */\n flex-shrink: 0;\n transition:\n color 120ms ease,\n background-color 120ms ease;\n }\n\n .stepper:hover:not(:disabled) {\n color: var(--sken-primary);\n /* No background fill on hover. A background would paint\n over the input's right border, hiding it. The color\n change to --sken-primary is enough affordance for a\n 20x24px icon button. */\n background: transparent;\n }\n\n .stepper:focus-visible {\n outline: 2px solid var(--sken-ring, currentColor);\n outline-offset: 1px;\n }\n\n .stepper:disabled {\n cursor: not-allowed;\n opacity: var(--sken-disabled);\n }\n\n /* When the ± steppers are visible, reserve the right edge\n of the input so the value text never overlaps. The\n reservation is 2rem for the md size (default): 1.5rem\n (button) + 2 * 0.25rem (column insets). The sm / lg\n variants below override the button size, so the\n reservation needs to scale too. */\n :host([show-stepper-buttons]) input {\n padding-inline-end: 2rem;\n }\n\n /* ── Stepper sizes ─────────────────────────────────────── */\n :host([size='sm']) .steppers {\n inset-block: 0.125rem;\n inset-inline-end: 0.125rem;\n gap: 1px;\n }\n :host([size='sm']) .stepper {\n inline-size: 1.125rem;\n block-size: 0.875rem;\n font-size: 0.75rem;\n }\n /* sm: 1.125rem (button) + 2 * 0.125rem (insets) = 1.375rem,\n plus 0.125rem of breathing air = 1.5rem. */\n :host([size='sm'][show-stepper-buttons]) input {\n padding-inline-end: 1.5rem;\n }\n\n :host([size='lg']) .steppers {\n inset-block: 0.375rem;\n inset-inline-end: 0.375rem;\n gap: 3px;\n }\n :host([size='lg']) .stepper {\n inline-size: 1.75rem;\n block-size: 1.5rem;\n font-size: 1.125rem;\n }\n /* lg: 1.75rem (button) + 2 * 0.375rem (insets) = 2.5rem,\n plus 0.25rem of breathing air = 2.75rem. */\n :host([size='lg'][show-stepper-buttons]) input {\n padding-inline-end: 2.75rem;\n }\n\n /* Hide steppers on touch devices where they would be hard\n to hit accurately. The user can still use ↑/↓ keys or\n type directly. */\n @media (hover: none) {\n .steppers {\n display: none;\n }\n input {\n padding-inline-end: var(--sken-3);\n }\n :host([size='sm']) input {\n padding-inline-end: var(--sken-2);\n }\n :host([size='lg']) input {\n padding-inline-end: var(--sken-4);\n }\n }\n\n /* ── Slot containers ────────────────────────────────────── */\n .slot {\n position: absolute;\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 1;\n pointer-events: none;\n color: var(--sken-muted-foreground);\n }\n\n .slot ::slotted(*) {\n pointer-events: auto;\n }\n\n .slot-start {\n inset-inline-start: 0.5rem;\n }\n\n /*\n * The end slot's position is set in JS via the inline style\n * (see #adjustLayout). The rule below is a fallback for the\n * initial render before JS has measured the slot, and a\n * sensible default when the slot is empty.\n */\n .slot-end {\n inset-inline-end: 0.5rem;\n }\n\n /* ── States ─────────────────────────────────────────────── */\n .input-wrapper:hover input:not(:disabled):not(:read-only) {\n border-color: var(--sken-foreground);\n }\n\n .input-wrapper:focus-within input {\n outline: 2px solid var(--sken-ring, currentColor);\n outline-offset: 2px;\n border-color: var(--sken-primary);\n }\n\n input:disabled {\n cursor: not-allowed;\n opacity: var(--sken-disabled);\n }\n\n input:read-only {\n background: var(--sken-muted);\n }\n\n :host([invalid]) input {\n border-color: var(--sken-destructive);\n }\n\n :host([invalid]) .input-wrapper:focus-within input {\n outline-color: var(--sken-destructive);\n }\n\n /* ── Sizes ──────────────────────────────────────────────── */\n :host([size='sm']) input {\n padding-block: var(--sken-1);\n padding-inline: var(--sken-2);\n font-size: var(--sken-size-sm);\n /* 2 × 0.875rem (buttons) + 1px (gap) + 2 × 0.125rem\n (insets) = ~2.0625rem. Round up to 2.25rem for visual\n breathing room. */\n min-block-size: 2.25rem;\n }\n :host([size='md']) input {\n /* Default styles above. */\n }\n :host([size='lg']) input {\n padding-block: var(--sken-3);\n padding-inline: var(--sken-4);\n font-size: var(--sken-size-lg);\n /* 2 × 1.5rem (buttons) + 3px (gap) + 2 × 0.375rem\n (insets) = ~3.9375rem. Round up to 4rem. */\n min-block-size: 4rem;\n }\n\n /* ── Validation messages ─────────────────────────────────── */\n .messages {\n display: grid;\n gap: 0.25rem;\n }\n\n .message {\n margin: 0;\n font-size: 0.75rem;\n line-height: 1.4;\n }\n\n .message[data-tone='info'] {\n color: var(--sken-info, #0082ff);\n }\n\n .message[data-tone='warning'] {\n color: var(--sken-warning, #f59e0b);\n }\n\n .message[data-tone='valid'] {\n color: var(--sken-success, #10b981);\n }\n\n .message[data-tone='invalid'] {\n color: var(--sken-destructive, #dc2626);\n }\n\n .message[data-tone='helper'] {\n color: var(--sken-muted-foreground);\n }\n `\n\n // ── Render ────────────────────────────────────────────────────\n protected override render() {\n const messages = this.#renderMessages()\n return html`\n <div class=\"field\" part=\"field\">\n ${\n this.label\n ? html`\n <label class=\"label\" part=\"label\" data-required=${this.required}>\n ${this.label}\n </label>\n `\n : nothing\n }\n <div class=\"input-wrapper\" part=\"wrapper\">\n <div class=\"slot slot-start\" part=\"slot-start\">\n <slot name=\"start\"></slot>\n </div>\n <input\n part=\"input\"\n type=\"number\"\n .value=${this.#rawText}\n placeholder=${this.placeholder ?? ''}\n ?disabled=${this.disabled}\n ?readonly=${this.readonly}\n ?required=${this.required}\n min=${this.min ?? ''}\n max=${this.max ?? ''}\n step=${this.step}\n aria-invalid=${this.invalid ? 'true' : 'false'}\n aria-describedby=${this.describedById ?? ''}\n name=${this.name ?? ''}\n style=\"text-align: ${this.textAlignment}\"\n @input=${this.#handleInput}\n @change=${this.#handleChange}\n @focus=${this.#handleFocus}\n @blur=${this.#handleBlur}\n @keydown=${this.#handleKeydown}\n />\n <div class=\"slot slot-end\" part=\"slot-end\">\n <slot name=\"end\"></slot>\n </div>\n ${\n this.showStepperButtons\n ? html`\n <div class=\"steppers\" part=\"steppers\">\n <button\n class=\"stepper\"\n part=\"stepper stepper-up\"\n type=\"button\"\n aria-label=\"Increment\"\n ?disabled=${\n this.disabled ||\n this.readonly ||\n (this.value !== undefined && this.max !== undefined && this.value >= this.max)\n }\n @click=${this.#handleStepUp}\n >\n +\n </button>\n <button\n class=\"stepper\"\n part=\"stepper stepper-down\"\n type=\"button\"\n aria-label=\"Decrement\"\n ?disabled=${\n this.disabled ||\n this.readonly ||\n (this.value !== undefined && this.min !== undefined && this.value <= this.min)\n }\n @click=${this.#handleStepDown}\n >\n −\n </button>\n </div>\n `\n : nothing\n }\n </div>\n ${\n messages.length > 0\n ? html` <div class=\"messages\" part=\"messages\">${messages}</div> `\n : nothing\n }\n </div>\n `\n }\n\n #renderMessages() {\n const messages: ReturnType<typeof html>[] = []\n if (this.invalidText) {\n messages.push(\n html`<p class=\"message\" data-tone=\"invalid\" part=\"message-invalid\">${this.invalidText}</p>`,\n )\n }\n if (this.warningText) {\n messages.push(\n html`<p class=\"message\" data-tone=\"warning\" part=\"message-warning\">${this.warningText}</p>`,\n )\n }\n if (this.infoText) {\n messages.push(\n html`<p class=\"message\" data-tone=\"info\" part=\"message-info\">${this.infoText}</p>`,\n )\n }\n if (this.validText) {\n messages.push(\n html`<p class=\"message\" data-tone=\"valid\" part=\"message-valid\">${this.validText}</p>`,\n )\n }\n if (this.helperText && messages.length === 0) {\n messages.push(\n html`<p class=\"message\" data-tone=\"helper\" part=\"message-helper\">${this.helperText}</p>`,\n )\n }\n return messages\n }\n\n // ── Event handlers ───────────────────────────────────────────\n #handleInput = (event: Event) => {\n const target = event.target as HTMLInputElement\n this.#rawText = target.value\n // Emit the parsed number (or undefined if empty / invalid).\n const parsed = this.#parseNumber(target.value)\n if (this.value === undefined) {\n // Uncontrolled: store internally, do not mutate this.value\n // because the contract is \"controlled by the consumer\".\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-input', {\n detail: parsed,\n bubbles: true,\n composed: true,\n }),\n )\n } else {\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-input', {\n detail: parsed,\n bubbles: true,\n composed: true,\n }),\n )\n }\n }\n\n #handleChange = (event: Event) => {\n const target = event.target as HTMLInputElement\n const parsed = this.#parseNumber(target.value)\n // Clamp on commit. If the user typed 150 with max=100, we\n // commit 100 and update the input text.\n const clamped = this.#clamp(parsed)\n if (clamped !== parsed) {\n this.#rawText = clamped !== undefined ? String(clamped) : ''\n target.value = this.#rawText\n }\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-change', {\n detail: clamped,\n bubbles: true,\n composed: true,\n }),\n )\n }\n\n #handleFocus = (_event: FocusEvent) => {\n this.dispatchEvent(new CustomEvent('sken-focus', { bubbles: true, composed: true }))\n }\n\n #handleBlur = (_event: FocusEvent) => {\n this.dispatchEvent(new CustomEvent('sken-blur', { bubbles: true, composed: true }))\n }\n\n #handleKeydown = (event: KeyboardEvent) => {\n if (this.disabled || this.readonly) return\n // Shift+Arrow and PageUp/PageDown apply a 10x multiplier to\n // the step. This is the standard \"fast forward\" pattern in\n // numeric inputs across Mature DS (iX, Material, AntD). It\n // lets the consumer reach the target faster when the step\n // is small relative to the typical range.\n const multiplier = event.shiftKey || event.key === 'PageUp' || event.key === 'PageDown' ? 10 : 1\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n this.#step(+1, multiplier)\n } else if (event.key === 'ArrowDown') {\n event.preventDefault()\n this.#step(-1, multiplier)\n } else if (event.key === 'PageUp') {\n event.preventDefault()\n this.#step(+1, 10)\n } else if (event.key === 'PageDown') {\n event.preventDefault()\n this.#step(-1, 10)\n } else if (event.key === 'Home' && this.min !== undefined) {\n event.preventDefault()\n this.#commitValue(this.min)\n } else if (event.key === 'End' && this.max !== undefined) {\n event.preventDefault()\n this.#commitValue(this.max)\n } else if (event.key === 'Enter') {\n // The browser fires a `change` event on Enter for\n // <input type=\"number\">, so we don't need to commit\n // anything manually. We DO need to bridge the shadow\n // boundary so the surrounding <form> receives a submit\n // event — the browser's implicit submission algorithm\n // does not see the inner input. Mirrors SkenInput and\n // SkenTextarea. `isComposing` guards IME composition.\n if (event.isComposing) return\n // preventDefault runs even without ElementInternals,\n // keeping the observable behaviour consistent.\n event.preventDefault()\n const form = this.#internals?.form\n if (!form) return\n form.requestSubmit()\n }\n }\n\n #handleStepUp = () => this.#step(+1)\n #handleStepDown = () => this.#step(-1)\n\n // ── Helpers ───────────────────────────────────────────────────\n /**\n * Increment / decrement the value by `step * multiplier`,\n * clamped at [min, max]. Emits `sken-input` and `sken-change`\n * (we treat the stepper as a commit, not a keystroke). The\n * `multiplier` defaults to 1; the keyboard handler passes 10\n * for Shift+Arrow and PageUp/PageDown (the \"fast forward\"\n * pattern).\n *\n * The current value is read from the rendered <input>, NOT\n * from `this.value`. Reading from the input is robust for\n * both modes:\n * - Controlled: the consumer is async (Vue's reactive update\n * applies on the next tick). By the time the second click\n * arrives, the consumer's `value` prop may still be the\n * pre-click value, but the input's `.value` is already\n * updated by #commitValue from the first click.\n * - Uncontrolled: the primitive owns the value. The input's\n * `.value` is the source of truth; `this.value` is the\n * initial seed and not maintained by the primitive in this\n * mode.\n */\n #step(direction: 1 | -1, multiplier: number = 1) {\n if (this.disabled || this.readonly) return\n const inputEl = this.renderRoot.querySelector('input') as HTMLInputElement | null\n const text = inputEl?.value ?? ''\n const current = text === '' ? (this.value ?? 0) : Number(text)\n if (!Number.isFinite(current)) return\n const next = current + direction * this.step * multiplier\n this.#commitValue(next)\n }\n\n /**\n * Commit a value: clamp, update internal state, emit events.\n * Same path used by the stepper buttons and the keyboard\n * Home / End shortcuts.\n */\n #commitValue(raw: number) {\n const clamped = this.#clamp(raw)\n this.#rawText = clamped !== undefined ? String(clamped) : ''\n // Update the DOM input so the next @input sees the new value\n const inputEl = this.renderRoot.querySelector('input') as HTMLInputElement | null\n if (inputEl) inputEl.value = this.#rawText\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-input', {\n detail: clamped,\n bubbles: true,\n composed: true,\n }),\n )\n this.dispatchEvent(\n new CustomEvent<number | undefined>('sken-change', {\n detail: clamped,\n bubbles: true,\n composed: true,\n }),\n )\n }\n\n #clamp(value: number | undefined): number | undefined {\n if (value === undefined || Number.isNaN(value)) return undefined\n let v = value\n if (this.min !== undefined && v < this.min) v = this.min\n if (this.max !== undefined && v > this.max) v = this.max\n return v\n }\n\n #parseNumber(text: string): number | undefined {\n if (text === '' || text === '-') return undefined\n const n = Number(text)\n if (Number.isNaN(n)) return undefined\n return n\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-number-input': SkenNumberInput\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"sken-switch.js","names":["#controlled","#getInput","#handleChange","#handleFocus","#handleBlur"],"sources":["../src/components/sken-switch.ts"],"sourcesContent":["// ── <sken-switch> — framework-ready Web Component ────────────────────\n// Wraps a native <input type=\"checkbox\"> with role=\"switch\" and a\n// custom track + thumb. For immediate-effect toggles (the action\n// applies on click, not at form submit). For selections that commit\n// on submit, use <sken-checkbox> instead.\n//\n// Contract uses `on` / `defaultOn` (not `checked` /\n// `defaultChecked`) to reinforce semantic intent: a switch is \"on\"\n// or \"off\", not \"checked\" or \"unchecked\". Requires @sken-ds/theme/css\n// (ADR-0006).\n\nimport { LitElement, html, css } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\n@customElement('sken-switch')\nexport class SkenSwitch extends LitElement {\n // `on` distinguishes \"uncontrolled\" (consumer never assigns the\n // prop) from \"controlled\" (consumer assigns `true` or `false`).\n // Lit's `@property({ type: Boolean })` collapses \"absent\" to\n // `false`, which makes the two cases indistinguishable. We use\n // a custom converter that maps the attribute presence\n // (`<sken-switch on>`) to `true` and the attribute absence to\n // `undefined`, and the prop setter distinguishes `undefined`\n // (\"uncontrolled\") from `true`/`false` (\"controlled\"). The Vue\n // adapter must NOT bind the `on` prop when the consumer did\n // not pass `:on`, so the setter is never called and the\n // primitive stays uncontrolled.\n private _on: boolean | undefined = undefined\n private _onSet: boolean = false\n // The custom converter maps attribute absence to `undefined`\n // (the \"uncontrolled\" sentinel) and attribute presence to\n // `true`. The prop setter also accepts `false` (controlled-\n // with-false) and `true` (controlled-with-true).\n @property({\n attribute: 'on',\n converter: {\n fromAttribute: (value: string | null) => (value === null ? undefined : true),\n toAttribute: (value: boolean | undefined) => (value ? '' : null),\n },\n hasChanged: () => true,\n })\n set on(v: boolean | undefined) {\n this._on = v\n this._onSet = v !== undefined\n this.requestUpdate()\n }\n get on(): boolean | undefined {\n return this._on\n }\n /** True when the consumer has explicitly bound the `on` prop. */\n get #controlled(): boolean {\n return this._onSet\n }\n\n @property({ attribute: 'default-on', type: Boolean })\n defaultOn: boolean | undefined = undefined\n @property({ reflect: true, type: Boolean }) disabled = false\n @property({ reflect: true, type: Boolean }) readonly = false\n @property({ reflect: true, type: Boolean }) required = false\n @property({ attribute: 'described-by-id' }) describedById: string | undefined = undefined\n @property() name: string | undefined = undefined\n\n // string | null matches lib.dom.d.ts; the contract normalizes to\n // string | undefined for the public API.\n @property({ attribute: 'aria-label' }) override ariaLabel: string | null = null\n\n // @state so Lit picks up changes and re-runs updated().\n @state() private _uncontrolled: boolean = false\n\n static styles = css`\n :host {\n display: inline-block;\n vertical-align: middle;\n }\n\n :host([disabled]) {\n cursor: not-allowed;\n opacity: var(--sken-disabled);\n }\n\n /* The <label> wraps the hidden input + visible track + slot\n label. It carries the layout styles so the whole control\n looks like a single inline-flex row. */\n label {\n display: inline-flex;\n align-items: center;\n gap: var(--sken-2);\n cursor: pointer;\n color: var(--sken-foreground);\n font-family: var(--sken-family-sans);\n font-size: var(--sken-size-md);\n line-height: 1.25;\n }\n\n :host([disabled]) label {\n cursor: not-allowed;\n }\n\n /* Visually hidden but accessible input (same pattern as sken-checkbox). */\n input {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n }\n\n .track {\n position: relative;\n box-sizing: border-box;\n inline-size: 2.5rem;\n block-size: 1.375rem;\n padding: 2px;\n border-radius: 999px;\n background: var(--sken-border);\n transition: background-color 160ms ease;\n }\n\n .thumb {\n display: block;\n inline-size: calc(1.375rem - 4px);\n block-size: calc(1.375rem - 4px);\n border-radius: 50%;\n background: var(--sken-input);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);\n transition: transform 160ms ease;\n transform: translateX(0);\n }\n\n :host([data-on]) .track {\n background: var(--sken-primary);\n }\n :host([data-on]) .thumb {\n transform: translateX(1.125rem);\n }\n\n :host(:focus-within) .track {\n outline: 2px solid var(--sken-ring, currentColor);\n outline-offset: 2px;\n }\n :host(:focus-within) {\n outline: none;\n }\n\n .label {\n user-select: none;\n }\n `\n\n override connectedCallback(): void {\n super.connectedCallback()\n if (this.on === undefined) {\n this._uncontrolled = this.defaultOn ?? false\n }\n }\n\n protected override updated(): void {\n const input = this.#getInput()\n if (!input) return\n const isOn = (this.#controlled ? this.on : this._uncontrolled) ?? false\n input.checked = isOn\n if (isOn) this.setAttribute('data-on', '')\n else this.removeAttribute('data-on')\n }\n\n protected override render() {\n // The visible UI (track + thumb + label) is wrapped in a\n // `<label>` element so clicking anywhere on it toggles the\n // hidden input. This is the standard WAI-ARIA pattern for a\n // custom-styled checkbox / switch. Without the label, the\n // visually-hidden input would never receive a click event and\n // the switch would look completely inert to mouse / touch.\n return html`\n <label>\n <input\n part=\"input\"\n type=\"checkbox\"\n role=\"switch\"\n aria-checked=${this.on === undefined ? String(this._uncontrolled) : String(this.on)}\n ?disabled=${this.disabled}\n ?readonly=${this.readonly}\n ?required=${this.required}\n aria-describedby=${this.describedById ?? ''}\n aria-label=${this.ariaLabel ?? ''}\n name=${this.name ?? ''}\n @change=${this.#handleChange}\n @focus=${this.#handleFocus}\n @blur=${this.#handleBlur}\n />\n <span class=\"track\" part=\"track\" aria-hidden=\"true\">\n <span class=\"thumb\" part=\"thumb\"></span>\n </span>\n <span class=\"label\" part=\"label\">\n <slot></slot>\n </span>\n </label>\n `\n }\n\n #handleChange = (event: Event) => {\n const target = event.target as HTMLInputElement\n const newValue = target.checked\n // Disabled: the browser already blocks the change, but we\n // double-check here in case the input was disabled\n // programmatically (no click event) or in case the consumer\n // toggled `disabled` mid-flight. Either way, do not advance\n // state.\n if (this.disabled) {\n target.checked = this.#controlled ? this.on === true : this._uncontrolled\n return\n }\n // Readonly: the native `<input type=\"checkbox\">` does NOT\n // block the toggle on click (readonly only affects text\n // inputs), so the input flips visually. We revert the flip\n // and skip the change event so the consumer never sees it.\n // The `data-on` attribute is set in `updated()` from the\n // controlled / uncontrolled value, so the visual stays in\n // sync after the revert.\n if (this.readonly) {\n const value = this.#controlled ? this.on === true : this._uncontrolled\n target.checked = value\n this.requestUpdate()\n return\n }\n // In uncontrolled mode, the consumer does not own the value,\n // so we update the internal `@state` and let Lit re-render.\n // In controlled mode, the consumer owns the value: we do NOT\n // touch the internal state (the consumer's next prop change\n // will drive the next render). Either way, the input's\n // `change` event already flipped `target.checked` in the DOM\n // and we emit `sken-change` for both modes so the consumer\n // can sync.\n if (!this.#controlled) {\n this._uncontrolled = newValue\n }\n this.dispatchEvent(\n new CustomEvent<boolean>('sken-change', { detail: newValue, bubbles: true, composed: true }),\n )\n }\n\n #handleFocus = (event: FocusEvent) => {\n this.dispatchEvent(\n new CustomEvent<FocusEvent>('sken-focus', { detail: event, bubbles: true, composed: true }),\n )\n }\n\n #handleBlur = (event: FocusEvent) => {\n this.dispatchEvent(\n new CustomEvent<FocusEvent>('sken-blur', { detail: event, bubbles: true, composed: true }),\n )\n }\n\n #getInput(): HTMLInputElement | null {\n return this.shadowRoot?.querySelector('input') ?? null\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-switch': SkenSwitch\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sken-switch.js","names":["#controlled","#getInput","#handleChange","#handleFocus","#handleBlur"],"sources":["../src/components/sken-switch.ts"],"sourcesContent":["// ── <sken-switch> — framework-ready Web Component ────────────────────\n// Wraps a native <input type=\"checkbox\"> with role=\"switch\" and a\n// custom track + thumb. For immediate-effect toggles (the action\n// applies on click, not at form submit). For selections that commit\n// on submit, use <sken-checkbox> instead.\n//\n// Contract uses `on` / `defaultOn` (not `checked` /\n// `defaultChecked`) to reinforce semantic intent: a switch is \"on\"\n// or \"off\", not \"checked\" or \"unchecked\". Requires @sken-ds/theme/css\n// (ADR-0006).\n\nimport { LitElement, css, html } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\n@customElement('sken-switch')\nexport class SkenSwitch extends LitElement {\n // `on` distinguishes \"uncontrolled\" (consumer never assigns the\n // prop) from \"controlled\" (consumer assigns `true` or `false`).\n // Lit's `@property({ type: Boolean })` collapses \"absent\" to\n // `false`, which makes the two cases indistinguishable. We use\n // a custom converter that maps the attribute presence\n // (`<sken-switch on>`) to `true` and the attribute absence to\n // `undefined`, and the prop setter distinguishes `undefined`\n // (\"uncontrolled\") from `true`/`false` (\"controlled\"). The Vue\n // adapter must NOT bind the `on` prop when the consumer did\n // not pass `:on`, so the setter is never called and the\n // primitive stays uncontrolled.\n private _on: boolean | undefined = undefined\n private _onSet: boolean = false\n // The custom converter maps attribute absence to `undefined`\n // (the \"uncontrolled\" sentinel) and attribute presence to\n // `true`. The prop setter also accepts `false` (controlled-\n // with-false) and `true` (controlled-with-true).\n @property({\n attribute: 'on',\n converter: {\n fromAttribute: (value: string | null) => (value === null ? undefined : true),\n toAttribute: (value: boolean | undefined) => (value ? '' : null),\n },\n hasChanged: () => true,\n })\n set on(v: boolean | undefined) {\n this._on = v\n this._onSet = v !== undefined\n this.requestUpdate()\n }\n get on(): boolean | undefined {\n return this._on\n }\n /** True when the consumer has explicitly bound the `on` prop. */\n get #controlled(): boolean {\n return this._onSet\n }\n\n @property({ attribute: 'default-on', type: Boolean })\n defaultOn: boolean | undefined = undefined\n @property({ reflect: true, type: Boolean }) disabled = false\n @property({ reflect: true, type: Boolean }) readonly = false\n @property({ reflect: true, type: Boolean }) required = false\n @property({ attribute: 'described-by-id' }) describedById: string | undefined = undefined\n @property() name: string | undefined = undefined\n\n // string | null matches lib.dom.d.ts; the contract normalizes to\n // string | undefined for the public API.\n @property({ attribute: 'aria-label' }) override ariaLabel: string | null = null\n\n // @state so Lit picks up changes and re-runs updated().\n @state() private _uncontrolled: boolean = false\n\n static styles = css`\n :host {\n display: inline-block;\n vertical-align: middle;\n }\n\n :host([disabled]) {\n cursor: not-allowed;\n opacity: var(--sken-disabled);\n }\n\n /* The <label> wraps the hidden input + visible track + slot\n label. It carries the layout styles so the whole control\n looks like a single inline-flex row. */\n label {\n display: inline-flex;\n align-items: center;\n gap: var(--sken-2);\n cursor: pointer;\n color: var(--sken-foreground);\n font-family: var(--sken-family-sans);\n font-size: var(--sken-size-md);\n line-height: 1.25;\n }\n\n :host([disabled]) label {\n cursor: not-allowed;\n }\n\n /* Visually hidden but accessible input (same pattern as sken-checkbox). */\n input {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n }\n\n .track {\n position: relative;\n box-sizing: border-box;\n inline-size: 2.5rem;\n block-size: 1.375rem;\n padding: 2px;\n border-radius: 999px;\n background: var(--sken-border);\n transition: background-color 160ms ease;\n }\n\n .thumb {\n display: block;\n inline-size: calc(1.375rem - 4px);\n block-size: calc(1.375rem - 4px);\n border-radius: 50%;\n background: var(--sken-input);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);\n transition: transform 160ms ease;\n transform: translateX(0);\n }\n\n :host([data-on]) .track {\n background: var(--sken-primary);\n }\n :host([data-on]) .thumb {\n transform: translateX(1.125rem);\n }\n\n :host(:focus-within) .track {\n outline: 2px solid var(--sken-ring, currentColor);\n outline-offset: 2px;\n }\n :host(:focus-within) {\n outline: none;\n }\n\n .label {\n user-select: none;\n }\n `\n\n override connectedCallback(): void {\n super.connectedCallback()\n if (this.on === undefined) {\n this._uncontrolled = this.defaultOn ?? false\n }\n }\n\n protected override updated(): void {\n const input = this.#getInput()\n if (!input) return\n const isOn = (this.#controlled ? this.on : this._uncontrolled) ?? false\n input.checked = isOn\n if (isOn) this.setAttribute('data-on', '')\n else this.removeAttribute('data-on')\n }\n\n protected override render() {\n // The visible UI (track + thumb + label) is wrapped in a\n // `<label>` element so clicking anywhere on it toggles the\n // hidden input. This is the standard WAI-ARIA pattern for a\n // custom-styled checkbox / switch. Without the label, the\n // visually-hidden input would never receive a click event and\n // the switch would look completely inert to mouse / touch.\n return html`\n <label>\n <input\n part=\"input\"\n type=\"checkbox\"\n role=\"switch\"\n aria-checked=${this.on === undefined ? String(this._uncontrolled) : String(this.on)}\n ?disabled=${this.disabled}\n ?readonly=${this.readonly}\n ?required=${this.required}\n aria-describedby=${this.describedById ?? ''}\n aria-label=${this.ariaLabel ?? ''}\n name=${this.name ?? ''}\n @change=${this.#handleChange}\n @focus=${this.#handleFocus}\n @blur=${this.#handleBlur}\n />\n <span class=\"track\" part=\"track\" aria-hidden=\"true\">\n <span class=\"thumb\" part=\"thumb\"></span>\n </span>\n <span class=\"label\" part=\"label\">\n <slot></slot>\n </span>\n </label>\n `\n }\n\n #handleChange = (event: Event) => {\n const target = event.target as HTMLInputElement\n const newValue = target.checked\n // Disabled: the browser already blocks the change, but we\n // double-check here in case the input was disabled\n // programmatically (no click event) or in case the consumer\n // toggled `disabled` mid-flight. Either way, do not advance\n // state.\n if (this.disabled) {\n target.checked = this.#controlled ? this.on === true : this._uncontrolled\n return\n }\n // Readonly: the native `<input type=\"checkbox\">` does NOT\n // block the toggle on click (readonly only affects text\n // inputs), so the input flips visually. We revert the flip\n // and skip the change event so the consumer never sees it.\n // The `data-on` attribute is set in `updated()` from the\n // controlled / uncontrolled value, so the visual stays in\n // sync after the revert.\n if (this.readonly) {\n const value = this.#controlled ? this.on === true : this._uncontrolled\n target.checked = value\n this.requestUpdate()\n return\n }\n // In uncontrolled mode, the consumer does not own the value,\n // so we update the internal `@state` and let Lit re-render.\n // In controlled mode, the consumer owns the value: we do NOT\n // touch the internal state (the consumer's next prop change\n // will drive the next render). Either way, the input's\n // `change` event already flipped `target.checked` in the DOM\n // and we emit `sken-change` for both modes so the consumer\n // can sync.\n if (!this.#controlled) {\n this._uncontrolled = newValue\n }\n this.dispatchEvent(\n new CustomEvent<boolean>('sken-change', { detail: newValue, bubbles: true, composed: true }),\n )\n }\n\n #handleFocus = (event: FocusEvent) => {\n this.dispatchEvent(\n new CustomEvent<FocusEvent>('sken-focus', { detail: event, bubbles: true, composed: true }),\n )\n }\n\n #handleBlur = (event: FocusEvent) => {\n this.dispatchEvent(\n new CustomEvent<FocusEvent>('sken-blur', { detail: event, bubbles: true, composed: true }),\n )\n }\n\n #getInput(): HTMLInputElement | null {\n return this.shadowRoot?.querySelector('input') ?? null\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-switch': SkenSwitch\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"sken-textarea.js","names":["#internalValue","#syncFormValue","#internals","#handleInput","#handleChange","#handleFocus","#handleBlur","#handleKeydown"],"sources":["../src/components/sken-textarea.ts"],"sourcesContent":["// ── <sken-textarea> — framework-ready Web Component ────────────────\n// Multiline text input. Wraps a native <textarea> with token-driven\n// styles and the same shadow-DOM event surface as <sken-input>.\n// Slots are placed above (start) and below (end) the textarea, not\n// to the sides — multiline editors don't lend themselves to\n// horizontal adornments the way single-line inputs do.\n//\n// Requires @sken-ds/theme/css (ADR-0006). Pairs with @sken-ds/contracts'\n// SkenTextareaProps / SkenTextareaSlots / SkenTextareaEmits.\n\nimport { LitElement, html, css } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\nimport type {\n SkenTextareaSize,\n SkenTextareaResize,\n} from '@sken-ds/contracts'\n\n@customElement('sken-textarea')\nexport class SkenTextarea extends LitElement {\n // Form-associated custom element. Opt in to the\n // ElementInternals API so this primitive participates in\n // the surrounding <form>'s submit/reset lifecycle and\n // FormData(form) picks up the textarea's value under its\n // `name` attribute. See SkenInput for the full rationale;\n // the pattern is identical. Lit docs:\n // https://lit.dev/docs/components/form/\n static formAssociated = true\n\n // The ElementInternals instance. Created in the constructor\n // via attachInternals(), which the browser only makes\n // available because `formAssociated` is true. Nullable to\n // gracefully degrade in test environments that do not\n // implement ElementInternals.\n #internals: ElementInternals | null = null\n\n constructor() {\n super()\n try {\n this.#internals = this.attachInternals()\n } catch {\n this.#internals = null\n }\n }\n\n @property({ reflect: true }) size: SkenTextareaSize = 'md'\n @property({ reflect: true, type: Number }) rows = 4\n @property({ reflect: true, type: Number }) cols: number | undefined = undefined\n @property({ attribute: 'max-length', type: Number }) maxLength: number | undefined = undefined\n @property({ reflect: true }) resize: SkenTextareaResize = 'vertical'\n @property() placeholder: string | undefined = undefined\n @property() value: string | undefined = undefined\n @property({ attribute: 'default-value' }) defaultValue: string | undefined = undefined\n @property({ reflect: true, type: Boolean }) disabled = false\n @property({ reflect: true, type: Boolean }) readonly = false\n @property({ reflect: true, type: Boolean }) required = false\n @property({ reflect: true, type: Boolean }) invalid = false\n @property({ attribute: 'described-by-id' }) describedById: string | undefined = undefined\n @property() name: string | undefined = undefined\n\n // Internal value for uncontrolled mode. Seeded from `defaultValue`\n // on first connection; from then on the DOM textarea keeps the\n // source of truth. Same pattern as SkenInput.\n #internalValue: string = ''\n\n static styles = css`\n :host {\n display: inline-block;\n inline-size: 100%;\n }\n\n .textarea-wrapper {\n position: relative;\n display: flex;\n flex-direction: column;\n inline-size: 100%;\n }\n\n textarea {\n box-sizing: border-box;\n inline-size: 100%;\n border: 1px solid var(--sken-border);\n border-radius: var(--sken-md);\n background: var(--sken-input);\n color: var(--sken-foreground);\n font-family: var(--sken-family-sans);\n font-size: var(--sken-size-md);\n line-height: 1.5;\n padding-block: var(--sken-2);\n padding-inline: var(--sken-3);\n min-block-size: 4.5rem;\n transition:\n border-color 120ms ease,\n box-shadow 120ms ease;\n /* Resize is controlled by the resize attribute, not by\n user-agent stylesheet, so consumers can override. */\n }\n\n textarea::placeholder {\n color: var(--sken-muted-foreground);\n opacity: 1;\n }\n\n /* ── Slot containers ──────────────────────────────────────\n Position: relative (not absolute) so the slotted content\n flows naturally above and below the textarea. The wrapper\n has a flex column, so the textarea's vertical padding is\n NOT auto-adjusted (unlike SkenInput). Slot height adds to\n the wrapper's natural height. */\n .slot {\n display: flex;\n align-items: center;\n color: var(--sken-muted-foreground);\n min-block-size: 0.25rem;\n }\n\n .slot-start {\n padding-block-end: 0.25rem;\n }\n\n .slot-end {\n padding-block-start: 0.25rem;\n }\n\n /* ── Sizes ────────────────────────────────────────────────── */\n :host([size='sm']) textarea {\n padding-block: var(--sken-1);\n padding-inline: var(--sken-2);\n font-size: var(--sken-size-sm);\n }\n :host([size='md']) textarea {\n /* Default styles above. */\n }\n :host([size='lg']) textarea {\n padding-block: var(--sken-3);\n padding-inline: var(--sken-4);\n font-size: var(--sken-size-lg);\n }\n\n /* ── Resize policies ────────────────────────────────────── */\n :host([resize='none']) textarea {\n resize: none;\n }\n :host([resize='vertical']) textarea {\n resize: vertical;\n }\n :host([resize='horizontal']) textarea {\n resize: horizontal;\n }\n :host([resize='both']) textarea {\n resize: both;\n }\n\n /* ── States ───────────────────────────────────────────────── */\n .textarea-wrapper:hover textarea:not(:disabled):not(:read-only) {\n border-color: var(--sken-foreground);\n }\n .textarea-wrapper:focus-within textarea {\n outline: 2px solid var(--sken-ring, currentColor);\n outline-offset: 2px;\n border-color: var(--sken-primary);\n }\n textarea:disabled {\n cursor: not-allowed;\n opacity: var(--sken-disabled);\n }\n textarea:read-only {\n background: var(--sken-muted);\n }\n :host([invalid]) textarea {\n border-color: var(--sken-destructive);\n }\n :host([invalid]) .textarea-wrapper:focus-within textarea {\n outline-color: var(--sken-destructive);\n }\n `\n\n override connectedCallback(): void {\n super.connectedCallback()\n if (this.value === undefined) {\n this.#internalValue = this.defaultValue ?? ''\n }\n // Publish the initial value to the surrounding form.\n this.#syncFormValue()\n }\n\n // ── Form-association callbacks ─────────────────────────────\n // formResetCallback: for uncontrolled mode, restore the seed\n // value and re-publish. For controlled mode, re-render and\n // let Lit re-publish the controlled value.\n formResetCallback(): void {\n if (this.value === undefined) {\n this.#internalValue = this.defaultValue ?? ''\n const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null\n if (ta) ta.value = this.#internalValue\n this.#syncFormValue()\n } else {\n this.requestUpdate()\n }\n }\n\n // formDisabledCallback: mirror the form's :disabled state onto\n // the prop. Lit re-renders; the inner <textarea> picks up\n // `?disabled=${this.disabled}` in the template.\n formDisabledCallback(isDisabled: boolean): void {\n this.disabled = isDisabled\n }\n\n // Publish the current value to the form. Called on every\n // change so FormData and submit/reset stay in sync.\n #syncFormValue(): void {\n if (!this.#internals) return\n const value = this.value ?? this.#internalValue\n this.#internals.setFormValue(value)\n }\n\n protected override render() {\n const value = this.value ?? this.#internalValue\n return html`\n <div class=\"textarea-wrapper\" part=\"wrapper\">\n <div class=\"slot slot-start\" part=\"slot-start\">\n <slot name=\"start\"></slot>\n </div>\n <textarea\n part=\"textarea\"\n .value=${value}\n rows=${this.rows}\n cols=${this.cols ?? ''}\n maxlength=${this.maxLength ?? ''}\n placeholder=${this.placeholder ?? ''}\n ?disabled=${this.disabled}\n ?readonly=${this.readonly}\n ?required=${this.required}\n aria-invalid=${this.invalid ? 'true' : 'false'}\n aria-describedby=${this.describedById ?? ''}\n name=${this.name ?? ''}\n @input=${this.#handleInput}\n @change=${this.#handleChange}\n @focus=${this.#handleFocus}\n @blur=${this.#handleBlur}\n @keydown=${this.#handleKeydown}\n ></textarea>\n <div class=\"slot slot-end\" part=\"slot-end\">\n <slot name=\"end\"></slot>\n </div>\n </div>\n `\n }\n\n // composed: true on every event so the framework adapter outside\n // the shadow boundary can listen. Mirrors SkenInput.\n #handleInput = (event: Event) => {\n const target = event.target as HTMLTextAreaElement\n const newValue = target.value\n if (this.value === undefined) this.#internalValue = newValue\n // Publish the new value to the surrounding form so submit\n // and FormData see the latest text. Per-keystroke is fine:\n // setFormValue is cheap.\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<string>('sken-input', { detail: newValue, bubbles: true, composed: true }),\n )\n }\n\n #handleChange = (event: Event) => {\n const target = event.target as HTMLTextAreaElement\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<string>('sken-change', {\n detail: target.value,\n bubbles: true,\n composed: true,\n }),\n )\n }\n\n #handleFocus = (event: FocusEvent) => {\n this.dispatchEvent(\n new CustomEvent<FocusEvent>('sken-focus', { detail: event, bubbles: true, composed: true }),\n )\n }\n\n #handleBlur = (event: FocusEvent) => {\n this.dispatchEvent(\n new CustomEvent<FocusEvent>('sken-blur', { detail: event, bubbles: true, composed: true }),\n )\n }\n\n // Wire Enter-on-the-inner-textarea to the host's associated\n // form. Mirrors SkenInput.#handleKeydown with one key\n // difference: textareas are multiline, so the user can press\n // Shift+Enter to insert a newline. Only Enter WITHOUT shift\n // submits. The browser's implicit submission algorithm does\n // not see the inner textarea (shadow boundary), so we bridge\n // it ourselves via `internals.form.requestSubmit()`.\n // `isComposing` guards IME composition (Enter to confirm a\n // kanji candidate is not a submit intent).\n #handleKeydown = (event: KeyboardEvent) => {\n if (event.key !== 'Enter' || event.isComposing) return\n if (event.shiftKey) return // newline; let the browser insert it\n if (this.disabled || this.readonly) return\n // preventDefault runs even without ElementInternals support,\n // keeping the observable behaviour consistent.\n event.preventDefault()\n const form = this.#internals?.form\n if (!form) return\n form.requestSubmit()\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-textarea': SkenTextarea\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sken-textarea.js","names":["#internalValue","#syncFormValue","#internals","#handleInput","#handleChange","#handleFocus","#handleBlur","#handleKeydown"],"sources":["../src/components/sken-textarea.ts"],"sourcesContent":["// ── <sken-textarea> — framework-ready Web Component ────────────────\n// Multiline text input. Wraps a native <textarea> with token-driven\n// styles and the same shadow-DOM event surface as <sken-input>.\n// Slots are placed above (start) and below (end) the textarea, not\n// to the sides — multiline editors don't lend themselves to\n// horizontal adornments the way single-line inputs do.\n//\n// Requires @sken-ds/theme/css (ADR-0006). Pairs with @sken-ds/contracts'\n// SkenTextareaProps / SkenTextareaSlots / SkenTextareaEmits.\n\nimport type { SkenTextareaResize, SkenTextareaSize } from '@sken-ds/contracts'\nimport { LitElement, css, html } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\n@customElement('sken-textarea')\nexport class SkenTextarea extends LitElement {\n // Form-associated custom element. Opt in to the\n // ElementInternals API so this primitive participates in\n // the surrounding <form>'s submit/reset lifecycle and\n // FormData(form) picks up the textarea's value under its\n // `name` attribute. See SkenInput for the full rationale;\n // the pattern is identical. Lit docs:\n // https://lit.dev/docs/components/form/\n static formAssociated = true\n\n // The ElementInternals instance. Created in the constructor\n // via attachInternals(), which the browser only makes\n // available because `formAssociated` is true. Nullable to\n // gracefully degrade in test environments that do not\n // implement ElementInternals.\n #internals: ElementInternals | null = null\n\n constructor() {\n super()\n try {\n this.#internals = this.attachInternals()\n } catch {\n this.#internals = null\n }\n }\n\n @property({ reflect: true }) size: SkenTextareaSize = 'md'\n @property({ reflect: true, type: Number }) rows = 4\n @property({ reflect: true, type: Number }) cols: number | undefined = undefined\n @property({ attribute: 'max-length', type: Number }) maxLength: number | undefined = undefined\n @property({ reflect: true }) resize: SkenTextareaResize = 'vertical'\n @property() placeholder: string | undefined = undefined\n @property() value: string | undefined = undefined\n @property({ attribute: 'default-value' }) defaultValue: string | undefined = undefined\n @property({ reflect: true, type: Boolean }) disabled = false\n @property({ reflect: true, type: Boolean }) readonly = false\n @property({ reflect: true, type: Boolean }) required = false\n @property({ reflect: true, type: Boolean }) invalid = false\n @property({ attribute: 'described-by-id' }) describedById: string | undefined = undefined\n @property() name: string | undefined = undefined\n\n // Internal value for uncontrolled mode. Seeded from `defaultValue`\n // on first connection; from then on the DOM textarea keeps the\n // source of truth. Same pattern as SkenInput.\n #internalValue: string = ''\n\n static styles = css`\n :host {\n display: inline-block;\n inline-size: 100%;\n }\n\n .textarea-wrapper {\n position: relative;\n display: flex;\n flex-direction: column;\n inline-size: 100%;\n }\n\n textarea {\n box-sizing: border-box;\n inline-size: 100%;\n border: 1px solid var(--sken-border);\n border-radius: var(--sken-md);\n background: var(--sken-input);\n color: var(--sken-foreground);\n font-family: var(--sken-family-sans);\n font-size: var(--sken-size-md);\n line-height: 1.5;\n padding-block: var(--sken-2);\n padding-inline: var(--sken-3);\n min-block-size: 4.5rem;\n transition:\n border-color 120ms ease,\n box-shadow 120ms ease;\n /* Resize is controlled by the resize attribute, not by\n user-agent stylesheet, so consumers can override. */\n }\n\n textarea::placeholder {\n color: var(--sken-muted-foreground);\n opacity: 1;\n }\n\n /* ── Slot containers ──────────────────────────────────────\n Position: relative (not absolute) so the slotted content\n flows naturally above and below the textarea. The wrapper\n has a flex column, so the textarea's vertical padding is\n NOT auto-adjusted (unlike SkenInput). Slot height adds to\n the wrapper's natural height. */\n .slot {\n display: flex;\n align-items: center;\n color: var(--sken-muted-foreground);\n min-block-size: 0.25rem;\n }\n\n .slot-start {\n padding-block-end: 0.25rem;\n }\n\n .slot-end {\n padding-block-start: 0.25rem;\n }\n\n /* ── Sizes ────────────────────────────────────────────────── */\n :host([size='sm']) textarea {\n padding-block: var(--sken-1);\n padding-inline: var(--sken-2);\n font-size: var(--sken-size-sm);\n }\n :host([size='md']) textarea {\n /* Default styles above. */\n }\n :host([size='lg']) textarea {\n padding-block: var(--sken-3);\n padding-inline: var(--sken-4);\n font-size: var(--sken-size-lg);\n }\n\n /* ── Resize policies ────────────────────────────────────── */\n :host([resize='none']) textarea {\n resize: none;\n }\n :host([resize='vertical']) textarea {\n resize: vertical;\n }\n :host([resize='horizontal']) textarea {\n resize: horizontal;\n }\n :host([resize='both']) textarea {\n resize: both;\n }\n\n /* ── States ───────────────────────────────────────────────── */\n .textarea-wrapper:hover textarea:not(:disabled):not(:read-only) {\n border-color: var(--sken-foreground);\n }\n .textarea-wrapper:focus-within textarea {\n outline: 2px solid var(--sken-ring, currentColor);\n outline-offset: 2px;\n border-color: var(--sken-primary);\n }\n textarea:disabled {\n cursor: not-allowed;\n opacity: var(--sken-disabled);\n }\n textarea:read-only {\n background: var(--sken-muted);\n }\n :host([invalid]) textarea {\n border-color: var(--sken-destructive);\n }\n :host([invalid]) .textarea-wrapper:focus-within textarea {\n outline-color: var(--sken-destructive);\n }\n `\n\n override connectedCallback(): void {\n super.connectedCallback()\n if (this.value === undefined) {\n this.#internalValue = this.defaultValue ?? ''\n }\n // Publish the initial value to the surrounding form.\n this.#syncFormValue()\n }\n\n // ── Form-association callbacks ─────────────────────────────\n // formResetCallback: for uncontrolled mode, restore the seed\n // value and re-publish. For controlled mode, re-render and\n // let Lit re-publish the controlled value.\n formResetCallback(): void {\n if (this.value === undefined) {\n this.#internalValue = this.defaultValue ?? ''\n const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null\n if (ta) ta.value = this.#internalValue\n this.#syncFormValue()\n } else {\n this.requestUpdate()\n }\n }\n\n // formDisabledCallback: mirror the form's :disabled state onto\n // the prop. Lit re-renders; the inner <textarea> picks up\n // `?disabled=${this.disabled}` in the template.\n formDisabledCallback(isDisabled: boolean): void {\n this.disabled = isDisabled\n }\n\n // Publish the current value to the form. Called on every\n // change so FormData and submit/reset stay in sync.\n #syncFormValue(): void {\n if (!this.#internals) return\n const value = this.value ?? this.#internalValue\n this.#internals.setFormValue(value)\n }\n\n protected override render() {\n const value = this.value ?? this.#internalValue\n return html`\n <div class=\"textarea-wrapper\" part=\"wrapper\">\n <div class=\"slot slot-start\" part=\"slot-start\">\n <slot name=\"start\"></slot>\n </div>\n <textarea\n part=\"textarea\"\n .value=${value}\n rows=${this.rows}\n cols=${this.cols ?? ''}\n maxlength=${this.maxLength ?? ''}\n placeholder=${this.placeholder ?? ''}\n ?disabled=${this.disabled}\n ?readonly=${this.readonly}\n ?required=${this.required}\n aria-invalid=${this.invalid ? 'true' : 'false'}\n aria-describedby=${this.describedById ?? ''}\n name=${this.name ?? ''}\n @input=${this.#handleInput}\n @change=${this.#handleChange}\n @focus=${this.#handleFocus}\n @blur=${this.#handleBlur}\n @keydown=${this.#handleKeydown}\n ></textarea>\n <div class=\"slot slot-end\" part=\"slot-end\">\n <slot name=\"end\"></slot>\n </div>\n </div>\n `\n }\n\n // composed: true on every event so the framework adapter outside\n // the shadow boundary can listen. Mirrors SkenInput.\n #handleInput = (event: Event) => {\n const target = event.target as HTMLTextAreaElement\n const newValue = target.value\n if (this.value === undefined) this.#internalValue = newValue\n // Publish the new value to the surrounding form so submit\n // and FormData see the latest text. Per-keystroke is fine:\n // setFormValue is cheap.\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<string>('sken-input', { detail: newValue, bubbles: true, composed: true }),\n )\n }\n\n #handleChange = (event: Event) => {\n const target = event.target as HTMLTextAreaElement\n this.#syncFormValue()\n this.dispatchEvent(\n new CustomEvent<string>('sken-change', {\n detail: target.value,\n bubbles: true,\n composed: true,\n }),\n )\n }\n\n #handleFocus = (event: FocusEvent) => {\n this.dispatchEvent(\n new CustomEvent<FocusEvent>('sken-focus', { detail: event, bubbles: true, composed: true }),\n )\n }\n\n #handleBlur = (event: FocusEvent) => {\n this.dispatchEvent(\n new CustomEvent<FocusEvent>('sken-blur', { detail: event, bubbles: true, composed: true }),\n )\n }\n\n // Wire Enter-on-the-inner-textarea to the host's associated\n // form. Mirrors SkenInput.#handleKeydown with one key\n // difference: textareas are multiline, so the user can press\n // Shift+Enter to insert a newline. Only Enter WITHOUT shift\n // submits. The browser's implicit submission algorithm does\n // not see the inner textarea (shadow boundary), so we bridge\n // it ourselves via `internals.form.requestSubmit()`.\n // `isComposing` guards IME composition (Enter to confirm a\n // kanji candidate is not a submit intent).\n #handleKeydown = (event: KeyboardEvent) => {\n if (event.key !== 'Enter' || event.isComposing) return\n if (event.shiftKey) return // newline; let the browser insert it\n if (this.disabled || this.readonly) return\n // preventDefault runs even without ElementInternals support,\n // keeping the observable behaviour consistent.\n event.preventDefault()\n const form = this.#internals?.form\n if (!form) return\n form.requestSubmit()\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-textarea': SkenTextarea\n }\n}\n"],"mappings":";;;;AAeO,IAAM,IAAN,cAA2B,EAAW;;EAQnB,KAAA,iBAAA;;CAOxB;CAEA,cAAc;EAqQI,AApQhB,MAAM,GAH8B,KAAA,KAAA,MAWgB,KAAA,OAAA,MACJ,KAAA,OAAA,GACoB,KAAA,OAAA,KAAA,GACe,KAAA,YAAA,KAAA,GAC3B,KAAA,SAAA,YACZ,KAAA,cAAA,KAAA,GACN,KAAA,QAAA,KAAA,GACqC,KAAA,eAAA,KAAA,GACtB,KAAA,WAAA,IACA,KAAA,WAAA,IACA,KAAA,WAAA,IACD,KAAA,UAAA,IAC0B,KAAA,gBAAA,KAAA,GACzC,KAAA,OAAA,KAAA,GAKd,KAAA,KAAA,IA4LT,KAAA,MAAA,MAAiB;GAE/B,IAAM,IADS,EAAM,OACG;GAMxB,AALI,KAAK,UAAU,KAAA,MAAW,KAAKA,KAAiB,IAIpD,KAAKC,GAAe,GACpB,KAAK,cACH,IAAI,YAAoB,cAAc;IAAE,QAAQ;IAAU,SAAS;IAAM,UAAU;GAAK,CAAC,CAC3F;EACF,GAEiB,KAAA,MAAA,MAAiB;GAChC,IAAM,IAAS,EAAM;GAErB,AADA,KAAKA,GAAe,GACpB,KAAK,cACH,IAAI,YAAoB,eAAe;IACrC,QAAQ,EAAO;IACf,SAAS;IACT,UAAU;GACZ,CAAC,CACH;EACF,GAEgB,KAAA,MAAA,MAAsB;GACpC,KAAK,cACH,IAAI,YAAwB,cAAc;IAAE,QAAQ;IAAO,SAAS;IAAM,UAAU;GAAK,CAAC,CAC5F;EACF,GAEe,KAAA,MAAA,MAAsB;GACnC,KAAK,cACH,IAAI,YAAwB,aAAa;IAAE,QAAQ;IAAO,SAAS;IAAM,UAAU;GAAK,CAAC,CAC3F;EACF,GAWkB,KAAA,MAAA,MAAyB;GAGzC,IAFI,EAAM,QAAQ,WAAW,EAAM,eAC/B,EAAM,YACN,KAAK,YAAY,KAAK,UAAU;GAGpC,EAAM,eAAe;GACrB,IAAM,IAAO,KAAKC,IAAY;GACzB,KACL,EAAK,cAAc;EACrB;EA7QE,IAAI;GACF,KAAKA,KAAa,KAAK,gBAAgB;EACzC,QAAQ;GACN,KAAKA,KAAa;EACpB;CACF;CAoBA;;EAEgB,KAAA,SAAA,CAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgHnB,oBAAmC;EAMjC,AALA,MAAM,kBAAkB,GACpB,KAAK,UAAU,KAAA,MACjB,KAAKF,KAAiB,KAAK,gBAAgB,KAG7C,KAAKC,GAAe;CACtB;CAMA,oBAA0B;EACxB,IAAI,KAAK,UAAU,KAAA,GAAW;GAC5B,KAAKD,KAAiB,KAAK,gBAAgB;GAC3C,IAAM,IAAK,KAAK,WAAW,cAAc,UAAU;GAEnD,AADI,MAAI,EAAG,QAAQ,KAAKA,KACxB,KAAKC,GAAe;EACtB,OACE,KAAK,cAAc;CAEvB;CAKA,qBAAqB,GAA2B;EAC9C,KAAK,WAAW;CAClB;CAIA,KAAuB;EACrB,IAAI,CAAC,KAAKC,IAAY;EACtB,IAAM,IAAQ,KAAK,SAAS,KAAKF;EACjC,KAAKE,GAAW,aAAa,CAAK;CACpC;CAEA,SAA4B;EAC1B,IAAM,IAAQ,KAAK,SAAS,KAAKF;EACjC,OAAO,CAAI;;;;;;;mBAOI,EAAM;iBACR,KAAK,KAAK;iBACV,KAAK,QAAQ,GAAG;sBACX,KAAK,aAAa,GAAG;wBACnB,KAAK,eAAe,GAAG;sBACzB,KAAK,SAAS;sBACd,KAAK,SAAS;sBACd,KAAK,SAAS;yBACX,KAAK,UAAU,SAAS,QAAQ;6BAC5B,KAAK,iBAAiB,GAAG;iBACrC,KAAK,QAAQ,GAAG;mBACd,KAAKG,GAAa;oBACjB,KAAKC,GAAc;mBACpB,KAAKC,GAAa;kBACnB,KAAKC,GAAY;qBACd,KAAKC,GAAe;;;;;;;CAOvC;CAIA;CAaA;CAYA;CAMA;CAeA;AAWF;AAvQG,EAAA,CAAA,EAAS,EAAE,SAAS,GAAK,CAAC,CAAA,GAAA,EAAA,WAAA,QAAA,KAAA,CAAA,GAC1B,EAAA,CAAA,EAAS;CAAE,SAAS;CAAM,MAAM;AAAO,CAAC,CAAA,GAAA,EAAA,WAAA,QAAA,KAAA,CAAA,GACxC,EAAA,CAAA,EAAS;CAAE,SAAS;CAAM,MAAM;AAAO,CAAC,CAAA,GAAA,EAAA,WAAA,QAAA,KAAA,CAAA,GACxC,EAAA,CAAA,EAAS;CAAE,WAAW;CAAc,MAAM;AAAO,CAAC,CAAA,GAAA,EAAA,WAAA,aAAA,KAAA,CAAA,GAClD,EAAA,CAAA,EAAS,EAAE,SAAS,GAAK,CAAC,CAAA,GAAA,EAAA,WAAA,UAAA,KAAA,CAAA,GAC1B,EAAA,CAAA,EAAS,CAAA,GAAA,EAAA,WAAA,eAAA,KAAA,CAAA,GACT,EAAA,CAAA,EAAS,CAAA,GAAA,EAAA,WAAA,SAAA,KAAA,CAAA,GACT,EAAA,CAAA,EAAS,EAAE,WAAW,gBAAgB,CAAC,CAAA,GAAA,EAAA,WAAA,gBAAA,KAAA,CAAA,GACvC,EAAA,CAAA,EAAS;CAAE,SAAS;CAAM,MAAM;AAAQ,CAAC,CAAA,GAAA,EAAA,WAAA,YAAA,KAAA,CAAA,GACzC,EAAA,CAAA,EAAS;CAAE,SAAS;CAAM,MAAM;AAAQ,CAAC,CAAA,GAAA,EAAA,WAAA,YAAA,KAAA,CAAA,GACzC,EAAA,CAAA,EAAS;CAAE,SAAS;CAAM,MAAM;AAAQ,CAAC,CAAA,GAAA,EAAA,WAAA,YAAA,KAAA,CAAA,GACzC,EAAA,CAAA,EAAS;CAAE,SAAS;CAAM,MAAM;AAAQ,CAAC,CAAA,GAAA,EAAA,WAAA,WAAA,KAAA,CAAA,GACzC,EAAA,CAAA,EAAS,EAAE,WAAW,kBAAkB,CAAC,CAAA,GAAA,EAAA,WAAA,iBAAA,KAAA,CAAA,GACzC,EAAA,CAAA,EAAS,CAAA,GAAA,EAAA,WAAA,QAAA,KAAA,CAAA,GAxCX,IAAA,EAAA,CAAA,EAAc,eAAe,CAAA,GAAA,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sken-ds/primitives",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public"
@@ -92,7 +92,7 @@
92
92
  "@tanstack/match-sorter-utils": "^9.0.0",
93
93
  "@tanstack/table-core": "^9.0.0",
94
94
  "lit": "^3.3.3",
95
- "@sken-ds/contracts": "0.3.7"
95
+ "@sken-ds/contracts": "0.3.8"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@vitest/coverage-v8": "^4.1.10",
@@ -1 +0,0 @@
1
- {"version":3,"file":"sken-dialog-hRtML2jW.js","names":["s","t","r","#dispatch","#hasTitle","#originalParent","#originalNextSibling","#portaled","#showModal","#onCancel","#onClose","#onClick","#onTitleSlotChange","#onCloseButton"],"sources":["../../../node_modules/.pnpm/lit-html@3.3.3/node_modules/lit-html/directives/class-map.js","../src/components/sken-dialog.ts"],"sourcesContent":["import{noChange as t}from\"../lit-html.js\";import{directive as s,Directive as i,PartType as r}from\"../directive.js\";\n/**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */const e=s(class extends i{constructor(t){if(super(t),t.type!==r.ATTRIBUTE||\"class\"!==t.name||t.strings?.length>2)throw Error(\"`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.\")}render(t){return\" \"+Object.keys(t).filter(s=>t[s]).join(\" \")+\" \"}update(s,[i]){if(void 0===this.st){this.st=new Set,void 0!==s.strings&&(this.nt=new Set(s.strings.join(\" \").split(/\\s/).filter(t=>\"\"!==t)));for(const t in i)i[t]&&!this.nt?.has(t)&&this.st.add(t);return this.render(i)}const r=s.element.classList;for(const t of this.st)t in i||(r.remove(t),this.st.delete(t));for(const t in i){const s=!!i[t];s===this.st.has(t)||this.nt?.has(t)||(s?(r.add(t),this.st.add(t)):(r.remove(t),this.st.delete(t)))}return t}});export{e as classMap};\n//# sourceMappingURL=class-map.js.map\n","// ── <sken-dialog> — WAI-ARIA modal built on <dialog> ──────────────────\n// A thin Sken wrapper around the native HTML <dialog> element.\n//\n// Why <dialog>:\n// The browser already implements the WAI-ARIA dialog pattern when\n// you call showModal(): focus trap, Tab cycling, ESC handling,\n// ::backdrop pseudo-element, top-layer rendering, and inert\n// siblings. Re-implementing those is what v1 of this file did\n// and it produced a worse result (Math.random() ids, position:\n// fixed hacks, hand-rolled scroll lock, manual ESC listener).\n// v2 uses the platform. Less code, fewer bugs, better a11y.\n//\n// Why controlled only:\n// The consumer owns the `open` state. The primitive never flips\n// it on its own. To close, the consumer listens for `sken-close`\n// and sets `open = false`. Same model as Radix Dialog, Headless\n// UI Dialog, Reach UI Dialog.\n//\n// Portal:\n// On first open, the host element is re-parented into\n// document.body. showModal() requires the <dialog> to be in the\n// top layer; if it sits inside a transformed / overflow:hidden\n// ancestor it can be clipped. Re-parenting is the robust fix.\n// The element is restored to its original parent on disconnect.\n\nimport { LitElement, html, css, nothing } from 'lit'\nimport { customElement, property, query } from 'lit/decorators.js'\nimport { classMap } from 'lit/directives/class-map.js'\nimport type { SkenDialogSize, SkenDialogDismissable, SkenDialogCloseReason } from '@sken-ds/contracts'\n\n@customElement('sken-dialog')\nexport class SkenDialog extends LitElement {\n @property({ reflect: true, type: Boolean }) open = false\n @property({ reflect: true }) size: SkenDialogSize = 'md'\n @property({ reflect: true }) dismissable: SkenDialogDismissable = 'dismissable'\n @property({ attribute: 'aria-label' }) ariaLabel: string | null = null\n @property({ attribute: 'aria-describedby' }) ariaDescribedBy: string | null = null\n\n @query('dialog') private _dialog!: HTMLDialogElement\n @query('.close-button') private _closeButton!: HTMLButtonElement | null\n\n /** Original parent of the host element. Restored on disconnect. */\n #originalParent: Node | null = null\n /** Next sibling in the original parent. Used to restore position. */\n #originalNextSibling: Node | null = null\n /** Has the host been moved into document.body? */\n #portaled = false\n /** Has the title slot been filled? Updated via the slotchange\n * event on the title <slot>. */\n #hasTitle = false\n\n static styles = css`\n :host {\n display: contents;\n }\n\n dialog {\n /* Reset native styles. */\n padding: 0;\n border: none;\n background: transparent;\n color: inherit;\n max-width: none;\n max-height: none;\n margin: auto;\n outline: none;\n }\n\n /* Open animation (entry). The browser fires the open attribute\n on showModal(); with transition-behavior: allow-discrete we\n can animate the top-layer entry. Falls back to no animation\n in browsers without support. */\n dialog[open] {\n animation: sken-dialog-enter 150ms ease-out;\n }\n @keyframes sken-dialog-enter {\n from {\n opacity: 0;\n transform: scale(0.96);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n }\n\n /* Backdrop. Native ::backdrop is a pseudo-element on the\n <dialog> when shown via showModal(). */\n dialog::backdrop {\n background: rgba(15, 18, 24, 0.55);\n animation: sken-dialog-backdrop-enter 150ms ease-out;\n }\n @keyframes sken-dialog-backdrop-enter {\n from { opacity: 0; }\n to { opacity: 1; }\n }\n\n .panel {\n background: var(--sken-color-surface, #ffffff);\n color: var(--sken-color-text, #1a1a1a);\n border-radius: 12px;\n box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.25);\n display: flex;\n flex-direction: column;\n max-height: 90vh;\n max-width: 90vw;\n overflow: hidden;\n font-family: var(--sken-font-sans, system-ui, sans-serif);\n }\n\n .panel.sm { width: 400px; }\n .panel.md { width: 560px; }\n .panel.lg { width: 800px; }\n\n header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n padding: 20px 24px 8px;\n }\n\n header ::slotted([slot='title']) {\n font-size: 18px;\n font-weight: 600;\n line-height: 1.3;\n margin: 0;\n }\n\n .body {\n padding: 8px 24px 16px;\n overflow-y: auto;\n flex: 1;\n }\n\n footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n padding: 12px 24px 20px;\n }\n\n /* Only render the footer divider if there is content. */\n footer:not(:has(*)) {\n display: none;\n }\n\n .close-button {\n appearance: none;\n background: transparent;\n border: none;\n cursor: pointer;\n width: 32px;\n height: 32px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: 6px;\n color: inherit;\n opacity: 0.6;\n transition: opacity 100ms, background 100ms;\n flex-shrink: 0;\n }\n .close-button:hover { opacity: 1; background: rgba(0, 0, 0, 0.05); }\n .close-button:focus-visible {\n outline: 2px solid var(--sken-color-focus, #2e6fc6);\n outline-offset: 1px;\n }\n `\n\n connectedCallback(): void {\n super.connectedCallback()\n // Capture original parent so we can restore on disconnect.\n this.#originalParent = this.parentNode\n this.#originalNextSibling = this.nextSibling\n }\n\n disconnectedCallback(): void {\n super.disconnectedCallback()\n if (this.#portaled && this.#originalParent) {\n // Restore original position. If originalNextSibling is null,\n // appendChild puts us at the end of originalParent.\n if (this.#originalNextSibling && this.#originalNextSibling.parentNode === this.#originalParent) {\n this.#originalParent.insertBefore(this, this.#originalNextSibling)\n } else {\n this.#originalParent.appendChild(this)\n }\n this.#portaled = false\n }\n }\n\n updated(changed: Map<string, unknown>): void {\n if (!changed.has('open')) return\n if (!this._dialog) return\n\n if (this.open) {\n this.#showModal()\n } else if (this._dialog.open) {\n this._dialog.close()\n }\n }\n\n /** Open the native <dialog>. Re-parents the host to <body> first. */\n #showModal(): void {\n if (this.#portaled === false && this.parentNode !== document.body) {\n document.body.appendChild(this)\n this.#portaled = true\n }\n if (!this._dialog.open) {\n this._dialog.showModal()\n this.#dispatch('sken-opened')\n // Focus the close button if dismissable, else the first focusable.\n queueMicrotask(() => {\n if (this.dismissable === 'dismissable' && this._closeButton) {\n this._closeButton.focus()\n } else {\n const first = this._dialog.querySelector<HTMLElement>(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n )\n first?.focus()\n }\n })\n }\n }\n\n /** Programmatic close request. Dispatches `sken-close` with the\n * given reason. Does NOT close the native <dialog> directly —\n * the consumer is expected to set `open = false` in response,\n * which the reactive `updated()` hook will translate into a\n * native close. This keeps the controlled-only contract honest:\n * the consumer always owns the `open` state.\n *\n * Use for cases where the close is initiated by code, not by\n * the user (form submission success, async operation complete,\n * etc.). */\n closeDialog(reason: SkenDialogCloseReason = 'close-button'): void {\n this.#dispatch('sken-close', reason)\n }\n\n /** Native <dialog> event: fired when the user presses ESC. We\n * translate to sken-close with reason 'escape'. The consumer\n * then sets `open = false` which calls _dialog.close() (a\n * no-op since it's already closed by the browser). */\n #onCancel = (e: Event): void => {\n if (this.dismissable === 'persistent') {\n // Prevent the browser from closing. The dialog stays open.\n e.preventDefault()\n return\n }\n // Browser will close. We dispatch sken-close; the consumer's\n // `open` prop will then become false and the next render\n // is a no-op since the dialog is already closed.\n this.#dispatch('sken-close', 'escape')\n }\n\n /** Native <dialog> event: fired on close (ESC, backdrop click,\n * close button, or .close()). We use it to fire sken-closed. */\n #onClose = (): void => {\n this.#dispatch('sken-closed')\n }\n\n /** Click handler on the <dialog> itself. If the click target is\n * the <dialog> (not a descendant), the user clicked the\n * backdrop. We treat it as a close request unless persistent. */\n #onClick = (e: MouseEvent): void => {\n if (e.target !== this._dialog) return\n if (this.dismissable === 'persistent') return\n this.#dispatch('sken-close', 'backdrop')\n }\n\n /** Click handler for the close button. Always visible when\n * dismissable; hidden when persistent. */\n #onCloseButton = (): void => {\n this.#dispatch('sken-close', 'close-button')\n }\n\n /** Lightweight event dispatcher with the conventions: composed\n * (crosses shadow boundary), bubbles (reaches ancestors). */\n #dispatch(name: 'sken-close', detail: SkenDialogCloseReason): void\n #dispatch(name: 'sken-opened' | 'sken-closed', detail?: undefined): void\n #dispatch(name: string, detail?: unknown): void {\n this.dispatchEvent(\n new CustomEvent(name, {\n detail,\n bubbles: true,\n composed: true,\n })\n )\n }\n\n render() {\n const panelClasses = {\n panel: true,\n [this.size]: true,\n }\n // We render the header only when there is a title slot OR the\n // dialog is dismissable (so the close button has somewhere to\n // live). A persistent dialog with no title renders body + footer\n // directly, no header. Slot presence is detected via\n // slotchange on the title slot.\n const showHeader = this.#hasTitle || this.dismissable === 'dismissable'\n // aria-label is only set when explicitly provided. Otherwise the\n // consumer should set a title slot so the dialog has an\n // accessible name via aria-labelledby. We always set the\n // attribute to a non-empty string (or omit it) so that a missing\n // name is detectable in dev tools.\n const ariaLabelAttr = this.ariaLabel ?? ''\n return html`\n <dialog\n aria-label=${ariaLabelAttr || nothing}\n aria-describedby=${this.ariaDescribedBy || nothing}\n @cancel=${this.#onCancel}\n @close=${this.#onClose}\n @click=${this.#onClick}\n >\n <div class=${classMap(panelClasses)}>\n ${showHeader\n ? html`<header>\n <slot name=\"title\" @slotchange=${this.#onTitleSlotChange}></slot>\n ${this.dismissable === 'dismissable'\n ? html`<button\n class=\"close-button\"\n type=\"button\"\n aria-label=\"Close dialog\"\n @click=${this.#onCloseButton}\n >\n ✕\n </button>`\n : nothing}\n </header>`\n : nothing}\n <div class=\"body\">\n <slot></slot>\n </div>\n <footer>\n <slot name=\"actions\"></slot>\n </footer>\n </div>\n </dialog>\n `\n }\n\n #onTitleSlotChange = (e: Event): void => {\n const slot = e.target as HTMLSlotElement\n this.#hasTitle = slot.assignedNodes({ flatten: true }).length > 0\n this.requestUpdate()\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'sken-dialog': SkenDialog\n }\n}\n"],"x_google_ignoreList":[0],"mappings":"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"}