@celestia-island/hikari 0.43.1 → 0.43.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.43.1",
3
+ "version": "0.43.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -177,9 +177,24 @@
177
177
  /* Menu/switcher panels overflow a bare 240px cap even on tall viewports
178
178
  (user feedback 2026-08-30: identity header + rows ≈ 320px+). Keep 240px
179
179
  as the floor for select lists on short viewports, otherwise let the
180
- panel size to its content up to a viewport-relative ceiling. */
180
+ panel size to its content up to a viewport-relative ceiling — plain-vh
181
+ first, dvh second, so an engine without dvh drops the dvh twin at PARSE
182
+ time and keeps the vh cap (the HkModal/HkDrawer/HkImageLightbox rule).
183
+ A consumer that needs a tighter surface (HkTagInput's catalog) publishes
184
+ --hk-select-panel-max-height through HkSelectPanel's `maxHeight` prop;
185
+ that hook rides the dvh-capable branch below, because a dvh value
186
+ reaching a dvh-less engine through var() would not be parse-dropped but
187
+ invalid at COMPUTED-value time — which resolves to `max-height: none`,
188
+ losing the cap entirely. Such an engine keeps the plain-vh ceiling
189
+ instead, exactly as it does today. */
181
190
  max-height: max(240px, min(36rem, calc(100vh - 32px)));
182
191
  max-height: max(240px, min(36rem, calc(100dvh - 32px)));
192
+ @supports (height: 1dvh) {
193
+ max-height: var(
194
+ --hk-select-panel-max-height,
195
+ max(240px, min(36rem, calc(100dvh - 32px)))
196
+ );
197
+ }
183
198
  display: flex;
184
199
  flex-direction: column;
185
200
  gap: var(--space-2);
@@ -371,6 +386,17 @@
371
386
  min-height: 0;
372
387
  overflow-y: auto;
373
388
  overscroll-behavior: contain;
389
+ /* The same consumer-tunable cap the popout reads, applied to the band
390
+ that actually scrolls: the sheet header keeps its place and the
391
+ catalog scrolls inside it. `none` (the fallback) is exactly today's
392
+ behaviour, so an untouched consumer's sheet is unchanged.
393
+ DELIBERATELY not wrapped in the popout's `@supports (height: 1dvh)`
394
+ guard: this band's fallback already IS the property's initial value,
395
+ so a value the engine cannot use resolves to the same `none` the
396
+ guard would produce — the asymmetry is a no-op, not an oversight.
397
+ The sheet PANEL above keeps its own plain-vh/dvh family cap either
398
+ way, so a hidden cap can never leave the sheet unbounded. */
399
+ max-height: var(--hk-select-panel-max-height, none);
374
400
  /* Side insets so the option blocks (full-row hover pills) read as
375
401
  cards inside the sheet instead of an edge-to-edge strip glued to
376
402
  both screen sides — matching the popover family's breathing room.
@@ -47,6 +47,15 @@ import "./HkSelect.scss";
47
47
  * events on the panel surface are forwarded (`keydown`) so owners that own
48
48
  * an option model (like HkSelect) can run their own arrow/enter navigation.
49
49
  *
50
+ * Geometry is the surface's to decide: `matchAnchorWidth` (default true,
51
+ * select parity) ties the popout's minimum width to the trigger, and the
52
+ * optional `maxHeight` caps whichever element scrolls (the desktop popout,
53
+ * the mobile sheet's list band). Both are opt-outs — a consumer that passes
54
+ * neither gets the historic panel, which is what every existing caller
55
+ * does; a consumer whose content should scroll inside a hugged surface
56
+ * (a tag catalog, a long filter list) sets them and relies on the panel's
57
+ * ONE scrollbar.
58
+ *
50
59
  * Mobile sheets also run a duplicate-title filter: content that opens with
51
60
  * a non-interactive heading exactly repeating the panel `title` is hidden
52
61
  * (`.hk-sheet-dup-title`) — the sheet header already names the sheet, and
@@ -85,6 +94,23 @@ export default defineComponent({
85
94
  offset: { type: Number, default: 4 },
86
95
  /** Popout min-width follows the anchor width (select parity). */
87
96
  matchAnchorWidth: { type: Boolean, default: true },
97
+ /** Cap for the surface that scrolls — the desktop popout or the
98
+ * mobile sheet's list band — as ANY CSS length (`min(18rem, 45dvh)`,
99
+ * `24rem`, `calc(50vh - 3rem)`).
100
+ *
101
+ * Undefined (the default) keeps the stylesheet's own ceilings, so
102
+ * every existing consumer is untouched; a consumer whose content
103
+ * should scroll instead of stretching the panel to the viewport sets
104
+ * it, and the panel's ONE scrollbar (never a second region) does the
105
+ * rest. Applied through the `--hk-select-panel-max-height` hook
106
+ * HkSelect.scss reads, inside its `@supports (height: 1dvh)` branch —
107
+ * a value an engine cannot use is therefore IGNORED rather than
108
+ * clamped: the popout keeps the plain-vh ceiling it has today.
109
+ * A MALFORMED value (a typo, a bare number) is likewise not sanitized:
110
+ * the substituted declaration becomes invalid at computed-value time
111
+ * and resolves to `max-height: none`, i.e. the surface is UNCAPPED —
112
+ * the type gate is a CSS length, the value gate is the consumer's. */
113
+ maxHeight: { type: String, default: undefined },
88
114
  /** Dock as a bottom sheet on phone-width viewports. */
89
115
  sheetOnMobile: { type: Boolean, default: true },
90
116
  },
@@ -320,6 +346,14 @@ export default defineComponent({
320
346
  const sheetBodyRef = ref<HTMLElement>();
321
347
  const coords = ref<{ top?: string; left?: string; minWidth?: string }>({});
322
348
 
349
+ /** Host-tunable surface cap (the `maxHeight` prop): published as the
350
+ * custom property HkSelect.scss reads on whichever element scrolls,
351
+ * so the stylesheet keeps today's ceiling as its fallback and a
352
+ * consumer that passes nothing renders exactly as before. */
353
+ const surfaceCap = computed<Record<string, string> | undefined>(() =>
354
+ props.maxHeight ? { "--hk-select-panel-max-height": props.maxHeight } : undefined,
355
+ );
356
+
323
357
  // ── overlay scrollbar (shared chrome) ─────────────────────────
324
358
  // Attached per open on whichever surface scrolls — the desktop
325
359
  // popout or the mobile sheet list — and detached on close/unmount
@@ -632,7 +666,11 @@ export default defineComponent({
632
666
  </HIconButton>
633
667
  </div>
634
668
  <div class="hk-select-sheet-body" ref={sheetBodyRef}>
635
- <div class="hk-select-sheet-list" ref={sheetListRef}>
669
+ <div
670
+ class="hk-select-sheet-list"
671
+ ref={sheetListRef}
672
+ style={surfaceCap.value}
673
+ >
636
674
  <div class="hk-select-sheet-content" ref={sheetContentRef}>
637
675
  {slots.default?.()}
638
676
  </div>
@@ -663,6 +701,7 @@ export default defineComponent({
663
701
  <div
664
702
  ref={panelRef}
665
703
  class="hk-select-popout"
704
+ style={surfaceCap.value}
666
705
  aria-label={props.title || undefined}
667
706
  onKeydown={forwardKeydown}
668
707
  >
@@ -3,7 +3,10 @@
3
3
  * HkInput's box (border, radius, rhythm, focus ring) so a tag field sits
4
4
  * flush beside plain inputs in the same form; the panel chrome mirrors
5
5
  * HkAffixPicker's popup (search on top, rows below, NO inner scroll
6
- * region — the HkSelectPanel window owns the single scrollbar). */
6
+ * region — the HkSelectPanel window owns the single scrollbar, capped by
7
+ * the component's `maxHeight` so a long catalog scrolls there instead of
8
+ * stretching the panel). Chips and selected rows are reorderable; the
9
+ * drag states (`data-dragging` / `data-drop`) live at the bottom. */
7
10
 
8
11
  .hk-tag-input-wrapper {
9
12
  display: block;
@@ -78,6 +81,15 @@
78
81
 
79
82
  .hk-tag-input-tag {
80
83
  max-width: 100%;
84
+ /* Reorder affordance: the chip BODY is the handle. */
85
+ cursor: grab;
86
+ /* A press that becomes a drag must not paint a text selection, and a
87
+ * touch that starts scrolling vertically belongs to the page (the
88
+ * browser takes the gesture back with a pointercancel; a horizontal
89
+ * drag is ours — see usePointerReorder). */
90
+ user-select: none;
91
+ -webkit-user-select: none;
92
+ touch-action: pan-y;
81
93
  }
82
94
 
83
95
  .hk-tag-input-tag-text {
@@ -87,6 +99,14 @@
87
99
  white-space: nowrap;
88
100
  }
89
101
 
102
+ /* A disabled field reorders nothing: the chips drop the grab affordance
103
+ * (and the gesture reservation with it) — the box's own not-allowed
104
+ * cursor takes over. */
105
+ .hk-tag-input-box[data-disabled] .hk-tag-input-tag {
106
+ cursor: default;
107
+ touch-action: auto;
108
+ }
109
+
90
110
  /* ── inline typing input / placeholder ────────────────────────────── */
91
111
  .hk-tag-input-element {
92
112
  /* Auto-growing: the element's width is published inline in `ch`
@@ -171,6 +191,12 @@
171
191
  .hk-tag-input-panel {
172
192
  display: flex;
173
193
  flex-direction: column;
194
+ /* A FIXED measure, not a content-driven one: the popout is positioned
195
+ * once when it opens, so a panel that grew with its content would shift
196
+ * its own left edge mid-interaction — toggling a row adds a check glyph,
197
+ * and a query changes which rows (and how much meta text) survive.
198
+ * min/max stay as the bounds a narrow viewport imposes on that width. */
199
+ width: min(19rem, calc(100vw - 2rem));
174
200
  min-width: 13rem;
175
201
  max-width: min(19rem, calc(100vw - 2rem));
176
202
  }
@@ -291,6 +317,62 @@
291
317
  font-variant-numeric: tabular-nums;
292
318
  }
293
319
 
320
+ /* ── reordering ───────────────────────────────────────────────────── */
321
+ /* Live drag (both lists): the item being moved dims, the slot it would
322
+ * land in is ringed. Chips carry the state as classes (HkTag takes no
323
+ * arbitrary attributes), panel rows as data attributes — both are
324
+ * assertable hooks that never collide with the pointer/hover faces. */
325
+ .hk-tag-input-tag-dragging,
326
+ .hk-tag-input-row[data-dragging] {
327
+ opacity: 0.55;
328
+ cursor: grabbing;
329
+ }
330
+
331
+ .hk-tag-input-tag-drop {
332
+ outline: 1px dashed rgb(var(--color-focused-border));
333
+ outline-offset: 1px;
334
+ }
335
+
336
+ .hk-tag-input-row[data-drop] {
337
+ box-shadow: inset 0 0 0 1px rgb(var(--color-focused-border));
338
+ }
339
+
340
+ /* Drag handle of a SELECTED panel row — the one element that claims the
341
+ * touch gesture outright, so the sheet keeps scrolling under the rest of
342
+ * the row. Only selected rows render one: an unselected row is not
343
+ * draggable and is never a drop target. */
344
+ .hk-tag-input-grip {
345
+ display: inline-flex;
346
+ align-items: center;
347
+ justify-content: center;
348
+ flex: none;
349
+ /* The largest target the row rhythm allows: the row's content box is the
350
+ 2.25rem row minus its 4px block padding, so the handle grows to that
351
+ without changing the option rows' height (the 44px touch guidance
352
+ cannot fit in a list row — the sheet scrolls through them instead). */
353
+ width: 1.5rem;
354
+ height: 1.75rem;
355
+ margin-inline-end: -4px;
356
+ color: rgb(var(--color-muted));
357
+ opacity: 0.6;
358
+ cursor: grab;
359
+ touch-action: none;
360
+ user-select: none;
361
+ -webkit-user-select: none;
362
+ transition: opacity 0.12s ease, color 0.12s ease;
363
+
364
+ &:active {
365
+ cursor: grabbing;
366
+ }
367
+ }
368
+
369
+ .hk-tag-input-row:hover .hk-tag-input-grip,
370
+ .hk-tag-input-row[data-dragging] .hk-tag-input-grip,
371
+ .hk-tag-input-grip:focus-visible {
372
+ opacity: 1;
373
+ color: rgb(var(--color-text));
374
+ }
375
+
294
376
  .hk-tag-input-empty {
295
377
  padding: 14px 12px;
296
378
  color: rgb(var(--color-muted));
@@ -301,6 +383,7 @@
301
383
  @media (prefers-reduced-motion: reduce) {
302
384
  .hk-tag-input-box,
303
385
  .hk-tag-input-chevron,
386
+ .hk-tag-input-grip,
304
387
  .hk-tag-input-row {
305
388
  transition: none;
306
389
  }
@@ -6,6 +6,14 @@
6
6
  * `max-height: 17rem; overflow: auto` row region. It has none: the
7
7
  * HkSelectPanel window surface (desktop popout / mobile sheet) owns THE
8
8
  * single scrollbar and scrolls the search field with the rows as one.
9
+ *
10
+ * The 2026-09-11 geometry wave leans on the same contract from the other
11
+ * side: the field used to hand the panel its own width (`matchAnchorWidth`,
12
+ * a full-width settings column) while the catalog caps at 19rem, leaving an
13
+ * empty band and a scrollbar parked far from the rows. The panel now hugs
14
+ * its own measure, and the catalog scrolls because the SURFACE is capped
15
+ * (`maxHeight` → the --hk-select-panel-max-height hook) — never because the
16
+ * row list grew a scroll region of its own.
9
17
  */
10
18
  import { describe, expect, it } from "vitest";
11
19
  import { readFileSync } from "node:fs";
@@ -34,6 +42,14 @@ function cssBlock(src: string, selector: string): string {
34
42
  return "";
35
43
  }
36
44
 
45
+ /** The JSX opening tag that carries `marker` (attributes only, no
46
+ * children) — the pin below is about what ONE element declares. */
47
+ function jsxOpeningTag(src: string, marker: string): string {
48
+ const at = src.indexOf(marker);
49
+ expect(at, `${marker} is rendered`).toBeGreaterThanOrEqual(0);
50
+ return src.slice(src.lastIndexOf("<", at), src.indexOf(">", at) + 1);
51
+ }
52
+
37
53
  function expectNoScroll(block: string, what: string): void {
38
54
  expect(block, `${what} block exists`).toBeTruthy();
39
55
  expect(block).not.toMatch(/max-height/);
@@ -61,13 +77,70 @@ describe("HkTagInput single-scrollbar-per-window contract", () => {
61
77
  expectNoScroll(block, ".hk-select-sheet-panel .hk-tag-input-panel");
62
78
  });
63
79
 
64
- it("the component mounts no overlay scrollbar machinery", () => {
80
+ it("the component mounts no overlay scrollbar machinery and clamps no row region", () => {
65
81
  expect(tsx).not.toContain("attachOverlayScrollbars");
66
82
  expect(tsx).not.toContain("OverlayScrollbarHandle");
67
- // No inline clamp on the row list either (the prose in the file
68
- // mentions `max-height` in the contract comment, so this pins the
69
- // style property spelling instead of the words).
70
- expect(tsx).not.toContain("maxHeight");
71
83
  expect(tsx).not.toContain("overflowY");
84
+ // The ONLY max-height in the file belongs to the panel SURFACE prop
85
+ // (the very next test): the listbox element itself stays unpinned, so
86
+ // the window keeps the one scroll region.
87
+ const listEl = jsxOpeningTag(tsx, 'class="hk-tag-input-list"');
88
+ expect(listEl.startsWith("<div")).toBe(true);
89
+ expect(listEl).not.toMatch(/style=/);
90
+ expect(listEl).not.toMatch(/maxHeight|max-height|overflow/);
91
+ });
92
+
93
+ it("hugs its own measure, and scrolls by capping the SURFACE (an additive prop)", () => {
94
+ const panel = jsxOpeningTag(tsx, "<HkSelectPanel");
95
+ // The field's width is not the panel's width any more…
96
+ expect(panel).toContain("matchAnchorWidth={false}");
97
+ // …and the catalog scrolls inside a capped surface instead of growing
98
+ // to the stylesheet ceiling.
99
+ expect(panel).toContain('maxHeight="min(18rem, 45dvh)"');
100
+ // The cap is the shared panel's own hook, and it is ADDITIVE: the
101
+ // historic ceiling stays the fallback, so every other consumer of
102
+ // HkSelectPanel renders exactly as before.
103
+ const panelScss = readFileSync(join(here, "HkSelect.scss"), "utf-8");
104
+ // The hook rides the dvh-capable branch: on an engine without dvh the
105
+ // plain-vh ceiling above it stays the cap — a var()-substituted dvh
106
+ // value would be invalid at computed-value time and mean "no cap".
107
+ const popout = panelScss.slice(
108
+ panelScss.indexOf(".hk-select-popout {"),
109
+ panelScss.indexOf(".hk-select-option"),
110
+ );
111
+ // …and the block's POSITION is load-bearing: the hook must come after
112
+ // both literal ceilings, or the dvh-capable branch would override them
113
+ // with its own fallback and the consumer cap would never apply.
114
+ expect(popout).toContain("@supports (height: 1dvh)");
115
+ expect(popout.indexOf("@supports")).toBeGreaterThan(
116
+ popout.indexOf("max-height: max(240px, min(36rem, calc(100vh - 32px)));"),
117
+ );
118
+ expect(popout.indexOf("@supports")).toBeGreaterThan(
119
+ popout.lastIndexOf("max-height: max(240px, min(36rem, calc(100dvh - 32px)));"),
120
+ );
121
+ expect(popout).toMatch(
122
+ /max-height:\s*var\(\s*--hk-select-panel-max-height,\s*max\(240px, min\(36rem, calc\(100dvh - 32px\)\)\)\s*\)/,
123
+ );
124
+ expect(popout).toContain("max-height: max(240px, min(36rem, calc(100vh - 32px)));");
125
+ // The sheet band the same prop caps falls back to being uncapped — the
126
+ // property's own initial value, i.e. today's behaviour.
127
+ expect(panelScss).toMatch(
128
+ /max-height:\s*var\(--hk-select-panel-max-height,\s*none\)/,
129
+ );
130
+ const panelTsx = readFileSync(join(here, "HkSelectPanel.tsx"), "utf-8");
131
+ expect(panelTsx).toContain("maxHeight: { type: String, default: undefined }");
132
+ });
133
+
134
+ it("animates both lists through the shared list transition (FLIP move)", () => {
135
+ const groups = [...tsx.matchAll(/<HkListTransition\b[^>]*>/g)].map((m) => m[0]);
136
+ expect(groups, "the chips and the rows are both transition groups").toHaveLength(2);
137
+ for (const group of groups) {
138
+ expect(group).toContain('variant="reveal"');
139
+ expect(group).toMatch(/\bmove\b/);
140
+ // A fragment tag: the group adds no wrapper element, so the chips
141
+ // stay the field's own flex items and the rows stay the listbox's
142
+ // own children.
143
+ expect(group).toContain('tag=""');
144
+ }
72
145
  });
73
146
  });