@fusion.dev/helix-core 0.32.0 → 0.33.0

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.
@@ -199,6 +199,15 @@
199
199
  },
200
200
  "description": "The box the readings are laid out in, which is the plot and not the host.\n\nThe host also holds the row of labels, so measuring against it would map\nthe pointer over a box taller than the drawing. That mattered the moment a\nchart started measuring down the box rather than across it, which is what\n`axis` is for: on a horizontal bar chart the labels are beside the plot\nrather than under it, and the host is wider than the drawing by exactly\ntheir column.\n\n**Held only while it is still in the tree.** Every chart renders a\ndifferent template for its empty state, so the plot a pointer crossed\nbefore the table filled is a node Lit has since replaced: held forever, it\nmeasures a detached box and the tooltip never appears. Looked up again\nwhenever the held one has left, which is once per data arrival rather than\nonce per move."
201
201
  },
202
+ {
203
+ "kind": "field",
204
+ "name": "mirrored",
205
+ "type": {
206
+ "text": "boolean"
207
+ },
208
+ "description": "Whether the drawing is turned to run right to left, which it is on a\nright-to-left page.\n\n`HelixElement` mirrors a drawing marked `data-mirror` there, so a reading\ndrawn at 10% of the plot's width stands 10% in from its right edge. What is\nread against the drawing has to be read the same way: the pointer from the\nplot's right, and a bubble beside a bar's far end on its left. Asked of the\nhost's computed `direction`, which is what the table and the splitter read\nfor their own arrow keys.\n\nOn the controller because it is what reads the pointer against the drawing,\nand a chart deciding which side a bubble goes asks here rather than\nrepeating the question. Asked when needed rather than held, since a page\ncan change its direction without the chart updating.",
209
+ "readonly": true
210
+ },
202
211
  {
203
212
  "kind": "method",
204
213
  "name": "set",
@@ -868,7 +877,7 @@
868
877
  "declarations": [
869
878
  {
870
879
  "kind": "class",
871
- "description": "Places one element against another, and keeps it there.\n\nPositioning is `@floating-ui/dom` rather than arithmetic written here. Two\nthings in particular are why. A `position: fixed` element inside an ancestor\ncarrying `transform`, `filter`, `will-change`, or `contain` is positioned\nagainst that ancestor and not the viewport, so viewport coordinates land it\nsomewhere else entirely. And an overlay has to clear its nearest *clipping*\nancestor, not just the window, or it is placed correctly inside a scrolling\npanel and clipped out of sight by it. Both are ordinary here, and both are a\nlong tail of browser behavior rather than a formula.\n\n`autoUpdate` keeps the position through scrolling on any ancestor, resizes of\neither element, and layout shifts.\n\nThe side the element actually landed on is written back as `data-placement`,\nso a styled tier can animate from it rather than from the side that was\nasked for.\n\n**The custom properties are declared here because this is what writes them.**\nEach carries a measurement taken against the viewport that no stylesheet can\ntake for itself, and `core` ships no CSS, so there is no reader inside this\npackage and none of them is private. the `hooks` rule pairs a documented\nhook with a rule that reads one, in both directions.",
880
+ "description": "Places one element against another, and keeps it there.\n\nPositioning is `@floating-ui/dom` rather than arithmetic written here. Two\nthings in particular are why. A `position: fixed` element inside an ancestor\ncarrying `transform`, `filter`, `will-change`, or `contain` is positioned\nagainst that ancestor and not the viewport, so viewport coordinates land it\nsomewhere else entirely. And an overlay has to clear its nearest *clipping*\nancestor, not just the window, or it is placed correctly inside a scrolling\npanel and clipped out of sight by it. Both are ordinary here, and both are a\nlong tail of browser behavior rather than a formula.\n\n`autoUpdate` keeps the position through scrolling on any ancestor, resizes of\neither element, and layout shifts.\n\nThe side the element actually landed on is written back as `data-placement`,\nso a styled tier can animate from it rather than from the side that was\nasked for. An arrow is placed whole, on the edge facing the anchor, so no\nstylesheet has to know which side that is to point it the right way.\n\n**The custom properties are declared here because this is what writes them.**\nEach carries a measurement taken against the viewport that no stylesheet can\ntake for itself, and `core` ships no CSS, so there is no reader inside this\npackage and none of them is private. the `hooks` rule pairs a documented\nhook with a rule that reads one, in both directions.",
872
881
  "name": "FloatingController",
873
882
  "cssProperties": [
874
883
  {
@@ -876,11 +885,11 @@
876
885
  "name": "--floating-anchor-inline-size"
877
886
  },
878
887
  {
879
- "description": "Where the pointer meets the anchor, along the inline axis.",
888
+ "description": "Where the pointer meets the anchor, from the panel's left edge in either direction.",
880
889
  "name": "--floating-arrow-x"
881
890
  },
882
891
  {
883
- "description": "Where the pointer meets the anchor, along the block axis.",
892
+ "description": "Where the pointer meets the anchor, from the panel's top edge.",
884
893
  "name": "--floating-arrow-y"
885
894
  },
886
895
  {
@@ -955,6 +964,16 @@
955
964
  },
956
965
  "description": "Stops placing and releases everything it was tracking."
957
966
  },
967
+ {
968
+ "kind": "method",
969
+ "name": "release",
970
+ "return": {
971
+ "type": {
972
+ "text": "void"
973
+ }
974
+ },
975
+ "description": "Takes back what placing wrote, for a surface its own sheet places now.\n\n**One surface can be both**, which is why this exists: a megamenu is a card\nits trigger places on a wide window and a band its sheet places on a narrow\none. Opened once as a card, it kept the card's edges and translate, and\ninline declarations win over the band's own sheet, so the band opened at\nthe card's offset with no right edge to stretch to. `stop` leaves them on\npurpose, since a panel transitioning out still has to be where it was."
976
+ },
958
977
  {
959
978
  "kind": "method",
960
979
  "name": "hostDisconnected",
@@ -977,6 +996,14 @@
977
996
  "module": "src/controllers/floating-controller/floating-controller.ts"
978
997
  }
979
998
  },
999
+ {
1000
+ "kind": "js",
1001
+ "name": "Side",
1002
+ "declaration": {
1003
+ "name": "Side",
1004
+ "module": "src/controllers/floating-controller/floating-controller.ts"
1005
+ }
1006
+ },
980
1007
  {
981
1008
  "kind": "js",
982
1009
  "name": "FloatingController",
@@ -1243,7 +1270,7 @@
1243
1270
  "declarations": [
1244
1271
  {
1245
1272
  "kind": "class",
1246
- "description": "Moves through a set of items with the arrow keys, and chooses as it goes.\n\n**Moving and choosing are one act here, not two.** That is the radio group\npattern and it is the difference from a listbox: moving focus without\nchoosing leaves a keyboard reader sitting on an item they have not selected,\nwith nothing on screen saying which of the two is true. A pointer user\nchooses by arriving, so a keyboard user does too.\n\n**Both axes, always.** A vertical set still answers left and right, and a\nhorizontal one still answers up and down. Binding only the axis the items\nhappen to be laid out on means a reader who guesses wrong gets nothing, and\n`orientation` is presentation: the same set of choices reads the same way\nwhichever direction it runs.\n\nDisabled items are skipped rather than stopped at, so a set with a gap in it\nstill moves in one press.\n\nA set that starts from focus rather than from the selection, or that moves\nwithout choosing, says so through `origin` and `selects`. Neither is a reason\nto keep a hand-written copy, and neither exempts a set from both axes.",
1273
+ "description": "Moves through a set of items with the arrow keys, and chooses as it goes.\n\n**Moving and choosing are one act here, not two.** That is the radio group\npattern and it is the difference from a listbox: moving focus without\nchoosing leaves a keyboard reader sitting on an item they have not selected,\nwith nothing on screen saying which of the two is true. A pointer user\nchooses by arriving, so a keyboard user does too.\n\n**Both axes, always.** A vertical set still answers left and right, and a\nhorizontal one still answers up and down. Binding only the axis the items\nhappen to be laid out on means a reader who guesses wrong gets nothing, and\n`orientation` is presentation: the same set of choices reads the same way\nwhichever direction it runs.\n\n**The horizontal pair turns with the line.** On a right-to-left page the\nfirst item is on the right, so left is forward there, which is what a native\nradio group does, measured in Chromium on 15 Sep 2026. Vertical sets\nincluded: the key a reader guesses on a column is the one that goes forward\nalong a row of the same items, and the two answering differently would make\n`orientation` more than presentation after all.\n\nDisabled items are skipped rather than stopped at, so a set with a gap in it\nstill moves in one press.\n\nA set that starts from focus rather than from the selection, or that moves\nwithout choosing, says so through `origin` and `selects`. Neither is a reason\nto keep a hand-written copy, and neither exempts a set from both axes.",
1247
1274
  "name": "KeyboardNavigationController",
1248
1275
  "members": [
1249
1276
  {
@@ -3529,7 +3556,7 @@
3529
3556
  "text": "Promise<void>"
3530
3557
  },
3531
3558
  "readonly": true,
3532
- "description": "Resolves a frame after the first update, when transitions are switched on.\n\n**Named `whenSettled` because `settled` is taken four times over**, by\nImage, Splitter and both color controls, none of them about transitions. A\nbase class takes the name every subclass then cannot use.\n\n**The state it reports cannot be watched, which is why this exists.** A\ncustom state is deliberately not in the DOM, so there is no\n`MutationObserver` equivalent for `ElementInternals.states`. Await this\ninstead.\n\nSeparate from `updateComplete`, which resolves in the microtask checkpoint\nthe suppression is still active in. That is the distinction `readyToMove`\nin `@fusion.dev/helix-testing` is written around.\n\nIt never resolves where `requestAnimationFrame` never fires, which is a\nserver, a hidden tab, and happy-dom. Callers that must not hang race it\nagainst a timeout; `readyToMove` does exactly that.",
3559
+ "description": "Resolves a frame after the first update, when transitions are switched on.\n\n**Named `whenSettled` because `settled` is taken four times over**, by\nImage, Splitter and both color controls, none of them about transitions. A\nbase class takes the name every subclass then cannot use.\n\n**The state it reports cannot be watched, which is why this exists.** A\ncustom state is deliberately not in the DOM, so there is no\n`MutationObserver` equivalent for `ElementInternals.states`. Await this\ninstead.\n\nSeparate from `updateComplete`, which resolves in the microtask checkpoint\nthe suppression is still active in. That is the distinction `readyToMove`\nin `@fusion.dev/helix-testing` is written around.\n\nIt never resolves where `requestAnimationFrame` never fires, which is a\nserver, a hidden tab, and happy-dom. Callers that must not hang there race\nit against a timeout, as the happy-dom harness's `readyToMove` does. The\nbrowser harness's awaits it alone: a browser always runs the frame, and a\ntimer beside it was a bet on how long one takes, which a busy run lost.",
3533
3560
  "default": "new Promise<void>((resolve) => { this.resolveSettled = resolve })"
3534
3561
  },
3535
3562
  {
@@ -4141,7 +4168,7 @@
4141
4168
  "text": "Promise<void>"
4142
4169
  },
4143
4170
  "readonly": true,
4144
- "description": "Resolves a frame after the first update, when transitions are switched on.\n\n**Named `whenSettled` because `settled` is taken four times over**, by\nImage, Splitter and both color controls, none of them about transitions. A\nbase class takes the name every subclass then cannot use.\n\n**The state it reports cannot be watched, which is why this exists.** A\ncustom state is deliberately not in the DOM, so there is no\n`MutationObserver` equivalent for `ElementInternals.states`. Await this\ninstead.\n\nSeparate from `updateComplete`, which resolves in the microtask checkpoint\nthe suppression is still active in. That is the distinction `readyToMove`\nin `@fusion.dev/helix-testing` is written around.\n\nIt never resolves where `requestAnimationFrame` never fires, which is a\nserver, a hidden tab, and happy-dom. Callers that must not hang race it\nagainst a timeout; `readyToMove` does exactly that.",
4171
+ "description": "Resolves a frame after the first update, when transitions are switched on.\n\n**Named `whenSettled` because `settled` is taken four times over**, by\nImage, Splitter and both color controls, none of them about transitions. A\nbase class takes the name every subclass then cannot use.\n\n**The state it reports cannot be watched, which is why this exists.** A\ncustom state is deliberately not in the DOM, so there is no\n`MutationObserver` equivalent for `ElementInternals.states`. Await this\ninstead.\n\nSeparate from `updateComplete`, which resolves in the microtask checkpoint\nthe suppression is still active in. That is the distinction `readyToMove`\nin `@fusion.dev/helix-testing` is written around.\n\nIt never resolves where `requestAnimationFrame` never fires, which is a\nserver, a hidden tab, and happy-dom. Callers that must not hang there race\nit against a timeout, as the happy-dom harness's `readyToMove` does. The\nbrowser harness's awaits it alone: a browser always runs the frame, and a\ntimer beside it was a bet on how long one takes, which a busy run lost.",
4145
4172
  "default": "new Promise<void>((resolve) => { this.resolveSettled = resolve })",
4146
4173
  "inheritedFrom": {
4147
4174
  "name": "HelixElement",
@@ -4568,6 +4595,14 @@
4568
4595
  "module": "./controllers/floating-controller/floating-controller.js"
4569
4596
  }
4570
4597
  },
4598
+ {
4599
+ "kind": "js",
4600
+ "name": "Side",
4601
+ "declaration": {
4602
+ "name": "Side",
4603
+ "module": "./controllers/floating-controller/floating-controller.js"
4604
+ }
4605
+ },
4571
4606
  {
4572
4607
  "kind": "js",
4573
4608
  "name": "FocusTrapController",
@@ -5528,6 +5563,14 @@
5528
5563
  "module": "./utils/inherit-disabled/inherit-disabled.js"
5529
5564
  }
5530
5565
  },
5566
+ {
5567
+ "kind": "js",
5568
+ "name": "inlineStep",
5569
+ "declaration": {
5570
+ "name": "inlineStep",
5571
+ "module": "./utils/inline-step/inline-step.js"
5572
+ }
5573
+ },
5531
5574
  {
5532
5575
  "kind": "js",
5533
5576
  "name": "Curve",
@@ -8070,6 +8113,25 @@
8070
8113
  "readonly": true,
8071
8114
  "description": "The overlay, placed against the field rather than the trigger."
8072
8115
  },
8116
+ {
8117
+ "kind": "method",
8118
+ "name": "placed",
8119
+ "privacy": "protected",
8120
+ "return": {
8121
+ "type": {
8122
+ "text": "void"
8123
+ }
8124
+ },
8125
+ "parameters": [
8126
+ {
8127
+ "name": "room",
8128
+ "type": {
8129
+ "text": "number | undefined"
8130
+ }
8131
+ }
8132
+ ],
8133
+ "description": "Told the panel has been placed, and how wide the window leaves it room to\nbe, in pixels.\n\nNothing, unless a family fits what its panel holds to the room it has."
8134
+ },
8073
8135
  {
8074
8136
  "kind": "method",
8075
8137
  "name": "restore",
@@ -8136,9 +8198,28 @@
8136
8198
  "name": "panel",
8137
8199
  "privacy": "protected",
8138
8200
  "readonly": true,
8139
- "default": "new PopoverController(this, { anchor: () => this.partNamed('field'), /* The surface *and* the trigger. * * A control is not always one element: the button that opens this panel is * part of it, and a press on it is not a press outside. Scoped to the * surface alone, `pointerdown` on the trigger closes the panel and the * trigger's own `toggleOpen` reopens it on the `click` that follows, so the * panel can never be closed by the button that opened it. * `DismissController` documents this failure and takes a list for it. */ boundary: (): readonly (Element | null | undefined)[] => [ this.partNamed('surface'), this.partNamed('trigger'), ], /* Wider than the four every other panel here takes. Those sit against a control the same width as themselves and read as an extension of it; this one is a picker of its own size hanging off a field, so the gap is what says the two are separate things rather than one control that grew. */ offset: () => 8, onDismiss: () => this.requestOpen(false), open: () => this.open, placement: 'bottom-start', surface: () => this.partNamed('surface'), })",
8201
+ "default": "new PopoverController(this, { anchor: () => this.partNamed('field'), /* The surface *and* the trigger. * * A control is not always one element: the button that opens this panel is * part of it, and a press on it is not a press outside. Scoped to the * surface alone, `pointerdown` on the trigger closes the panel and the * trigger's own `toggleOpen` reopens it on the `click` that follows, so the * panel can never be closed by the button that opened it. * `DismissController` documents this failure and takes a list for it. */ boundary: (): readonly (Element | null | undefined)[] => [ this.partNamed('surface'), this.partNamed('trigger'), ], /* Wider than the four every other panel here takes. Those sit against a control the same width as themselves and read as an extension of it; this one is a picker of its own size hanging off a field, so the gap is what says the two are separate things rather than one control that grew. */ offset: () => 8, onDismiss: () => this.requestOpen(false), onPlace: (_placement, room) => this.placed(room), open: () => this.open, placement: 'bottom-start', surface: () => this.partNamed('surface'), })",
8140
8202
  "description": "The overlay, placed against the field rather than the trigger.\n\nAgainst the field because that is the box a reader sees as the control: a\ncalendar hung off a small button at one end of a wide field reads as\nbelonging to the button. `Select` anchors to its trigger for the opposite\nreason, its trigger being the whole control."
8141
8203
  },
8204
+ {
8205
+ "kind": "method",
8206
+ "name": "placed",
8207
+ "privacy": "protected",
8208
+ "return": {
8209
+ "type": {
8210
+ "text": "void"
8211
+ }
8212
+ },
8213
+ "parameters": [
8214
+ {
8215
+ "name": "_room",
8216
+ "type": {
8217
+ "text": "number | undefined"
8218
+ }
8219
+ }
8220
+ ],
8221
+ "description": "Told the panel has been placed, and how wide the window leaves it room to\nbe, in pixels.\n\nNothing, unless a family fits what its panel holds to the room it has."
8222
+ },
8142
8223
  {
8143
8224
  "kind": "field",
8144
8225
  "name": "anchor",
@@ -9224,6 +9305,46 @@
9224
9305
  }
9225
9306
  ]
9226
9307
  },
9308
+ {
9309
+ "kind": "javascript-module",
9310
+ "path": "src/utils/inline-step/inline-step.ts",
9311
+ "declarations": [
9312
+ {
9313
+ "kind": "function",
9314
+ "name": "inlineStep",
9315
+ "return": {
9316
+ "type": {
9317
+ "text": "-1 | 0 | 1"
9318
+ }
9319
+ },
9320
+ "parameters": [
9321
+ {
9322
+ "name": "element",
9323
+ "type": {
9324
+ "text": "Element"
9325
+ }
9326
+ },
9327
+ {
9328
+ "name": "key",
9329
+ "type": {
9330
+ "text": "string"
9331
+ }
9332
+ }
9333
+ ],
9334
+ "description": "Which way along the line of text a horizontal arrow key moves.\n\n`1` for the key pointing where the line runs, `-1` for the one pointing back\nto where it started, `0` for any other key. On a left-to-right page that is\n`ArrowRight` and `ArrowLeft`; on a right-to-left page it is the other way\nround, because a row of tabs, a toolbar or a week of days is laid out from\nthe right there and an arrow is a promise about where on screen focus goes.\n\n**The computed `direction`, not `:dir()` or the `dir` attribute.** The two\npart only where a sheet sets `direction` without `dir`, and there the row is\nlaid out by `direction`: a flex row and a grid both follow it. What a reader\nsees is the order the key has to agree with, and it is the question the\ntable's and the splitter's pointer arithmetic already ask.\n\nAsked at the press rather than once, so a page that changes `dir` is\nanswered in its new direction without anything having to watch it.\n\nUp and down are not asked about: a block runs down the page in every script\nhere, so a vertical key means the same thing whichever way the line runs."
9335
+ }
9336
+ ],
9337
+ "exports": [
9338
+ {
9339
+ "kind": "js",
9340
+ "name": "inlineStep",
9341
+ "declaration": {
9342
+ "name": "inlineStep",
9343
+ "module": "src/utils/inline-step/inline-step.ts"
9344
+ }
9345
+ }
9346
+ ]
9347
+ },
9227
9348
  {
9228
9349
  "kind": "javascript-module",
9229
9350
  "path": "src/utils/line-path/line-path.ts",
@@ -93,6 +93,23 @@ export declare class ChartPointerController implements ReactiveController {
93
93
  * once per move.
94
94
  */
95
95
  private box;
96
+ /**
97
+ * Whether the drawing is turned to run right to left, which it is on a
98
+ * right-to-left page.
99
+ *
100
+ * `HelixElement` mirrors a drawing marked `data-mirror` there, so a reading
101
+ * drawn at 10% of the plot's width stands 10% in from its right edge. What is
102
+ * read against the drawing has to be read the same way: the pointer from the
103
+ * plot's right, and a bubble beside a bar's far end on its left. Asked of the
104
+ * host's computed `direction`, which is what the table and the splitter read
105
+ * for their own arrow keys.
106
+ *
107
+ * On the controller because it is what reads the pointer against the drawing,
108
+ * and a chart deciding which side a bubble goes asks here rather than
109
+ * repeating the question. Asked when needed rather than held, since a page
110
+ * can change its direction without the chart updating.
111
+ */
112
+ get mirrored(): boolean;
96
113
  /** Puts the pointer on a reading, for a chart that resolves it some other way. */
97
114
  set(at: number): void;
98
115
  private readonly move;
@@ -13,7 +13,11 @@ export class ChartPointerController {
13
13
  if (!box || !along || count === 0) {
14
14
  return;
15
15
  }
16
- const from = this.axis === 'block' ? event.clientY - box.top : event.clientX - box.left;
16
+ const from = this.axis === 'block'
17
+ ? event.clientY - box.top
18
+ : this.mirrored
19
+ ? box.right - event.clientX
20
+ : event.clientX - box.left;
17
21
  const slot = Math.floor((from / along) * count);
18
22
  const next = Math.min(Math.max(slot, 0), count - 1);
19
23
  if (next !== this.at) {
@@ -55,6 +59,9 @@ export class ChartPointerController {
55
59
  }
56
60
  return this.plot?.getBoundingClientRect();
57
61
  }
62
+ get mirrored() {
63
+ return typeof getComputedStyle === 'function' && getComputedStyle(this.host).direction === 'rtl';
64
+ }
58
65
  set(at) {
59
66
  if (at !== this.at) {
60
67
  this.at = at;
@@ -1,6 +1,6 @@
1
- import { type Placement } from '@floating-ui/dom';
1
+ import { type Placement, type Side } from '@floating-ui/dom';
2
2
  import type { ReactiveController, ReactiveControllerHost } from 'lit';
3
- export type { Placement };
3
+ export type { Placement, Side };
4
4
  export interface FloatingOptions {
5
5
  /** The element being pointed at. */
6
6
  anchor: () => HTMLElement | null | undefined;
@@ -19,14 +19,20 @@ export interface FloatingOptions {
19
19
  */
20
20
  flip?: boolean;
21
21
  /**
22
- * Told the placement the element landed on, after every measurement.
22
+ * Told the placement the element landed on, after every measurement, and
23
+ * how wide the window leaves it room to be.
23
24
  *
24
- * `data-placement` already says it, on the floating element, which is inside
25
- * the host's shadow root. A stylesheet outside reaches that element through
26
- * `::part()`, and `::part()` cannot be followed by an attribute selector, so
27
- * a host whose surface is drawn from outside records the side on itself.
25
+ * `data-placement` already says the side, on the floating element, which is
26
+ * inside the host's shadow root. A stylesheet outside reaches that element
27
+ * through `::part()`, and `::part()` cannot be followed by an attribute
28
+ * selector, so a host whose surface is drawn from outside records the side on
29
+ * itself.
30
+ *
31
+ * The room is in pixels, and absent where `resize` is off. It is for a host
32
+ * that changes what it shows to fit, which a stylesheet cannot do when what
33
+ * changes is markup: Date Input shows one month instead of two.
28
34
  */
29
- onPlace?: (placement: Placement) => void;
35
+ onPlace?: (placement: Placement, room: number | undefined) => void;
30
36
  /**
31
37
  * Space between the anchor and the floating element, in pixels.
32
38
  *
@@ -87,7 +93,8 @@ export interface FloatingOptions {
87
93
  *
88
94
  * The side the element actually landed on is written back as `data-placement`,
89
95
  * so a styled tier can animate from it rather than from the side that was
90
- * asked for.
96
+ * asked for. An arrow is placed whole, on the edge facing the anchor, so no
97
+ * stylesheet has to know which side that is to point it the right way.
91
98
  *
92
99
  * **The custom properties are declared here because this is what writes them.**
93
100
  * Each carries a measurement taken against the viewport that no stylesheet can
@@ -96,8 +103,8 @@ export interface FloatingOptions {
96
103
  * hook with a rule that reads one, in both directions.
97
104
  *
98
105
  * @cssproperty --floating-anchor-inline-size - How wide the anchor is, for a panel that matches it.
99
- * @cssproperty --floating-arrow-x - Where the pointer meets the anchor, along the inline axis.
100
- * @cssproperty --floating-arrow-y - Where the pointer meets the anchor, along the block axis.
106
+ * @cssproperty --floating-arrow-x - Where the pointer meets the anchor, from the panel's left edge in either direction.
107
+ * @cssproperty --floating-arrow-y - Where the pointer meets the anchor, from the panel's top edge.
101
108
  * @cssproperty --floating-available-block-size - How much room is left below, for a panel that scrolls.
102
109
  */
103
110
  export declare class FloatingController implements ReactiveController {
@@ -138,6 +145,17 @@ export declare class FloatingController implements ReactiveController {
138
145
  start(): void;
139
146
  /** Stops placing and releases everything it was tracking. */
140
147
  stop(): void;
148
+ /**
149
+ * Takes back what placing wrote, for a surface its own sheet places now.
150
+ *
151
+ * **One surface can be both**, which is why this exists: a megamenu is a card
152
+ * its trigger places on a wide window and a band its sheet places on a narrow
153
+ * one. Opened once as a card, it kept the card's edges and translate, and
154
+ * inline declarations win over the band's own sheet, so the band opened at
155
+ * the card's offset with no right edge to stretch to. `stop` leaves them on
156
+ * purpose, since a panel transitioning out still has to be where it was.
157
+ */
158
+ release(): void;
141
159
  /** Measures both elements and writes the position. */
142
160
  readonly update: () => void;
143
161
  hostDisconnected(): void;
@@ -1,4 +1,5 @@
1
1
  import { arrow, autoUpdate, computePosition, flip, offset, shift, size, } from '@floating-ui/dom';
2
+ const FACING = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' };
2
3
  const corner = (floating) => {
3
4
  const style = getComputedStyle(floating);
4
5
  const corners = [
@@ -36,6 +37,7 @@ export class FloatingController {
36
37
  ? this.options.placement()
37
38
  : this.options.placement;
38
39
  const middleware = [offset(gap ?? 0)];
40
+ let room;
39
41
  if (this.options.flip !== false) {
40
42
  const side = (preferred ?? 'bottom-start').split('-')[0];
41
43
  middleware.push(flip(side === 'left' || side === 'right'
@@ -48,7 +50,8 @@ export class FloatingController {
48
50
  }
49
51
  if (this.options.resize !== false) {
50
52
  middleware.push(size({
51
- apply: ({ availableHeight, elements, rects }) => {
53
+ apply: ({ availableHeight, availableWidth, elements, rects }) => {
54
+ room = availableWidth;
52
55
  elements.floating.style.setProperty('--floating-available-block-size', `${Math.round(availableHeight)}px`);
53
56
  elements.floating.style.setProperty('--floating-anchor-inline-size', `${Math.round(rects.reference.width)}px`);
54
57
  },
@@ -67,21 +70,35 @@ export class FloatingController {
67
70
  return;
68
71
  }
69
72
  this.placement = placement;
73
+ for (const [edge, value] of [
74
+ ['bottom', 'auto'],
75
+ ['left', '0'],
76
+ ['right', 'auto'],
77
+ ['top', '0'],
78
+ ]) {
79
+ floating.style.setProperty(edge, value, 'important');
80
+ }
70
81
  Object.assign(floating.style, {
71
- insetBlockStart: '0',
72
- insetInlineStart: '0',
73
82
  position: 'fixed',
74
83
  translate: `${Math.round(x)}px ${Math.round(y)}px`,
75
84
  });
76
85
  floating.setAttribute('data-placement', placement);
77
86
  floating.setAttribute('data-placed', '');
78
- this.options.onPlace?.(placement);
87
+ this.options.onPlace?.(placement, room === undefined ? undefined : Math.floor(room));
79
88
  const point = middlewareData.arrow;
80
89
  if (pointer && point) {
81
- Object.assign(pointer.style, {
82
- insetBlockStart: point.y === undefined ? '' : `${point.y}px`,
83
- insetInlineStart: point.x === undefined ? '' : `${point.x}px`,
84
- });
90
+ const facing = FACING[placement.split('-')[0]];
91
+ const reach = (facing === 'left' || facing === 'right' ? pointer.offsetWidth : pointer.offsetHeight) / 2;
92
+ const edges = {
93
+ bottom: 'auto',
94
+ left: point.x === undefined ? 'auto' : `${point.x}px`,
95
+ right: 'auto',
96
+ top: point.y === undefined ? 'auto' : `${point.y}px`,
97
+ };
98
+ edges[facing] = `${-reach}px`;
99
+ for (const [edge, value] of Object.entries(edges)) {
100
+ pointer.style.setProperty(edge, value, 'important');
101
+ }
85
102
  floating.style.setProperty('--floating-arrow-x', point.x === undefined ? '' : `${Math.round(point.x + pointer.offsetWidth / 2)}px`);
86
103
  floating.style.setProperty('--floating-arrow-y', point.y === undefined ? '' : `${Math.round(point.y + pointer.offsetHeight / 2)}px`);
87
104
  }
@@ -106,6 +123,31 @@ export class FloatingController {
106
123
  this.padding = undefined;
107
124
  this.options.floating()?.removeAttribute('data-placed');
108
125
  }
126
+ release() {
127
+ const floating = this.options.floating();
128
+ if (!floating) {
129
+ return;
130
+ }
131
+ for (const one of [
132
+ '--floating-anchor-inline-size',
133
+ '--floating-arrow-x',
134
+ '--floating-arrow-y',
135
+ '--floating-available-block-size',
136
+ 'bottom',
137
+ 'left',
138
+ 'position',
139
+ 'right',
140
+ 'top',
141
+ 'translate',
142
+ ]) {
143
+ floating.style.removeProperty(one);
144
+ }
145
+ const pointer = this.options.arrow?.();
146
+ for (const one of ['bottom', 'left', 'right', 'top']) {
147
+ pointer?.style.removeProperty(one);
148
+ }
149
+ floating.removeAttribute('data-placement');
150
+ }
109
151
  hostDisconnected() {
110
152
  this.stop();
111
153
  }
@@ -82,6 +82,13 @@ export interface KeyboardNavigationOptions {
82
82
  * `orientation` is presentation: the same set of choices reads the same way
83
83
  * whichever direction it runs.
84
84
  *
85
+ * **The horizontal pair turns with the line.** On a right-to-left page the
86
+ * first item is on the right, so left is forward there, which is what a native
87
+ * radio group does, measured in Chromium on 15 Sep 2026. Vertical sets
88
+ * included: the key a reader guesses on a column is the one that goes forward
89
+ * along a row of the same items, and the two answering differently would make
90
+ * `orientation` more than presentation after all.
91
+ *
85
92
  * Disabled items are skipped rather than stopped at, so a set with a gap in it
86
93
  * still moves in one press.
87
94
  *
@@ -1,3 +1,4 @@
1
+ import { inlineStep } from '../../utils/inline-step/inline-step.js';
1
2
  const KEYS = new Set(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home']);
2
3
  export class KeyboardNavigationController {
3
4
  constructor(host, { items, origin = 'selection', reserved = () => [], selects = () => true, wrap = true, }) {
@@ -16,11 +17,12 @@ export class KeyboardNavigationController {
16
17
  return;
17
18
  }
18
19
  const last = items.length - 1;
20
+ const along = inlineStep(this.host, event.key);
19
21
  let next = current;
20
- if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
22
+ if (along === 1 || event.key === 'ArrowDown') {
21
23
  next = this.wrap ? (current + 1) % items.length : Math.min(current + 1, last);
22
24
  }
23
- else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
25
+ else if (along === -1 || event.key === 'ArrowUp') {
24
26
  next =
25
27
  current < 0
26
28
  ? last
@@ -57,10 +57,11 @@ export interface PopoverOptions {
57
57
  */
58
58
  onDismiss: () => void;
59
59
  /**
60
- * Told the placement the surface landed on, after every measurement. Passed
61
- * through to the floating pass, which says why a host would want it.
60
+ * Told the placement the surface landed on, after every measurement, and how
61
+ * wide the window leaves it room to be. Passed through to the floating pass,
62
+ * which says why a host would want either.
62
63
  */
63
- onPlace?: (placement: Placement) => void;
64
+ onPlace?: (placement: Placement, room: number | undefined) => void;
64
65
  /**
65
66
  * Whether the host wants the surface up, read after every update.
66
67
  *
@@ -56,6 +56,9 @@ export class PopoverController {
56
56
  if (this.places) {
57
57
  this.floating.start();
58
58
  }
59
+ else {
60
+ this.floating.release();
61
+ }
59
62
  this.dismisser.activate();
60
63
  }
61
64
  hide() {
@@ -137,8 +137,10 @@ export declare class HelixElement extends LitElement {
137
137
  * in `@fusion.dev/helix-testing` is written around.
138
138
  *
139
139
  * It never resolves where `requestAnimationFrame` never fires, which is a
140
- * server, a hidden tab, and happy-dom. Callers that must not hang race it
141
- * against a timeout; `readyToMove` does exactly that.
140
+ * server, a hidden tab, and happy-dom. Callers that must not hang there race
141
+ * it against a timeout, as the happy-dom harness's `readyToMove` does. The
142
+ * browser harness's awaits it alone: a browser always runs the frame, and a
143
+ * timer beside it was a bet on how long one takes, which a busy run lost.
142
144
  */
143
145
  readonly whenSettled: Promise<void>;
144
146
  /** Resolves `whenSettled`, once, from the frame that records the state. */
@@ -18,6 +18,11 @@ const SCROLLER = css `
18
18
  overflow: auto;
19
19
  }
20
20
  `;
21
+ const MIRROR = css `
22
+ :host(:dir(rtl)) [data-mirror] {
23
+ scale: -1 1;
24
+ }
25
+ `;
21
26
  const SETTLED = css `
22
27
  :host(:not(:state(settled))),
23
28
  :host(:not(:state(settled))) * {
@@ -103,7 +108,7 @@ export class HelixElement extends LitElement {
103
108
  }));
104
109
  }
105
110
  static finalizeStyles(styles) {
106
- return [BOX_SIZING, HIDDEN, SCROLLER, SETTLED, ...LitElement.finalizeStyles(styles)];
111
+ return [BOX_SIZING, HIDDEN, SCROLLER, MIRROR, SETTLED, ...LitElement.finalizeStyles(styles)];
107
112
  }
108
113
  firstUpdated(changed) {
109
114
  super.firstUpdated(changed);
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { CopyController, type CopyOptions, } from './controllers/copy-controller
5
5
  export { DisabledCascadeController, type DisabledCascadeOptions, } from './controllers/disabled-cascade-controller/disabled-cascade-controller.js';
6
6
  export { DismissController, type DismissOptions, } from './controllers/dismiss-controller/dismiss-controller.js';
7
7
  export { DrilldownController, type DrilldownOptions, } from './controllers/drilldown-controller/drilldown-controller.js';
8
- export { FloatingController, type FloatingOptions, type Placement, } from './controllers/floating-controller/floating-controller.js';
8
+ export { FloatingController, type FloatingOptions, type Placement, type Side, } from './controllers/floating-controller/floating-controller.js';
9
9
  export { FocusTrapController } from './controllers/focus-trap-controller/focus-trap-controller.js';
10
10
  export { IndicatorController, type IndicatorOptions, } from './controllers/indicator-controller/indicator-controller.js';
11
11
  export { KeyboardNavigationController, type KeyboardNavigationOptions, type NavigableItem, } from './controllers/keyboard-navigation-controller/keyboard-navigation-controller.js';
@@ -40,6 +40,7 @@ export { deepActiveElement, deepContains, focusableElements, } from './utils/foc
40
40
  export { formatNumbers } from './utils/format-numbers/format-numbers.js';
41
41
  export { type HostAria, hostAria } from './utils/host-aria/host-aria.js';
42
42
  export { type DisableableItem, forgetDisabled, inheritDisabled, } from './utils/inherit-disabled/inherit-disabled.js';
43
+ export { inlineStep } from './utils/inline-step/inline-step.js';
43
44
  export { type Curve, linePath, type Point } from './utils/line-path/line-path.js';
44
45
  export { onUnlessFalse } from './utils/on-unless-false/on-unless-false.js';
45
46
  export { type Row, type RowCell, readRows, readSeries, type Series, type SeriesPoint, } from './utils/read-series/read-series.js';
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ export { deepActiveElement, deepContains, focusableElements, } from './utils/foc
40
40
  export { formatNumbers } from './utils/format-numbers/format-numbers.js';
41
41
  export { hostAria } from './utils/host-aria/host-aria.js';
42
42
  export { forgetDisabled, inheritDisabled, } from './utils/inherit-disabled/inherit-disabled.js';
43
+ export { inlineStep } from './utils/inline-step/inline-step.js';
43
44
  export { linePath } from './utils/line-path/line-path.js';
44
45
  export { onUnlessFalse } from './utils/on-unless-false/on-unless-false.js';
45
46
  export { readRows, readSeries, } from './utils/read-series/read-series.js';
@@ -41,6 +41,13 @@ export declare class MixinPickerFieldInterface {
41
41
  protected readonly handleKeydown: (event: KeyboardEvent) => void;
42
42
  /** The overlay, placed against the field rather than the trigger. */
43
43
  protected readonly panel: PopoverController;
44
+ /**
45
+ * Told the panel has been placed, and how wide the window leaves it room to
46
+ * be, in pixels.
47
+ *
48
+ * Nothing, unless a family fits what its panel holds to the room it has.
49
+ */
50
+ protected placed(room: number | undefined): void;
44
51
  /** Puts the field back where the answer differs from what was typed. */
45
52
  protected restore(): void;
46
53
  /** Puts the caret back in the field without the field reopening the panel. */
@@ -11,6 +11,7 @@ export const MixinPickerField = (Base) => {
11
11
  ],
12
12
  offset: () => 8,
13
13
  onDismiss: () => this.requestOpen(false),
14
+ onPlace: (_placement, room) => this.placed(room),
14
15
  open: () => this.open,
15
16
  placement: 'bottom-start',
16
17
  surface: () => this.partNamed('surface'),
@@ -24,6 +25,8 @@ export const MixinPickerField = (Base) => {
24
25
  };
25
26
  this.returning = false;
26
27
  }
28
+ placed(_room) {
29
+ }
27
30
  get anchor() {
28
31
  return this.control;
29
32
  }
@@ -3,12 +3,35 @@ import { type StaticValue } from 'lit/static-html.js';
3
3
  import type { Row } from '../read-series/read-series.js';
4
4
  /** Where a tooltip points, in the plot's own percentages, and which side it prefers. */
5
5
  export interface TooltipAt {
6
+ /**
7
+ * Which way the bubble's words run, for a chart whose plot holds a direction
8
+ * the page does not have.
9
+ *
10
+ * Absent, the bubble takes the plot's, which is the page's for every chart
11
+ * but a scatter: its plane is left to right on every page, and the bubble is
12
+ * a row's names and values, which read the way the page does.
13
+ */
14
+ dir?: 'ltr' | 'rtl';
15
+ /**
16
+ * Whether `x` is measured from the plot's left in either direction, for a
17
+ * drawing that does not turn with the text.
18
+ *
19
+ * Absent measures from the plot's inline start, which is where a drawing
20
+ * marked `data-mirror` starts: its left, or its right on a right-to-left page.
21
+ * A whole ring is the case for this, its bearing being a clock's, and a
22
+ * scatter's plane, whose across is a measure and not the line of text.
23
+ */
24
+ fixed?: boolean;
6
25
  /**
7
26
  * The side the bubble prefers. It moves if that side does not fit.
27
+ *
28
+ * Physical, as `Tooltip`'s own is: a chart that mirrors says `left` for the
29
+ * side it would call `right` on a left-to-right page.
8
30
  */
9
31
  placement: 'bottom' | 'left' | 'right' | 'top';
10
32
  /**
11
- * How far across the plot the reading is, as a percentage.
33
+ * How far across the plot the reading is, as a percentage, from the plot's
34
+ * inline start unless `fixed` says its left.
12
35
  */
13
36
  x: string;
14
37
  /**
@@ -16,10 +16,16 @@ export const drawTooltip = (rows, at, tag, where) => {
16
16
  return html `<div
17
17
  id="helix-chart-mark"
18
18
  part="mark"
19
- style=${styleMap({ insetBlockStart: place.y, insetInlineStart: place.x })}
19
+ style=${styleMap(place.fixed
20
+ ? { left: place.x, top: place.y }
21
+ : { insetBlockStart: place.y, insetInlineStart: place.x })}
20
22
  ></div>
21
23
  <${tag}
22
- exportparts="arrow: tooltip-arrow, tooltip"
24
+ dir=${place.dir ?? nothing}
25
+ exportparts="arrow: tooltip-arrow, arrow-bottom: tooltip-arrow-bottom,
26
+ arrow-left: tooltip-arrow-left, arrow-right: tooltip-arrow-right,
27
+ arrow-top: tooltip-arrow-top, tooltip, tooltip-bottom, tooltip-left, tooltip-right,
28
+ tooltip-top"
23
29
  for="helix-chart-mark"
24
30
  open
25
31
  part="tooltip-anchor"
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Which way along the line of text a horizontal arrow key moves.
3
+ *
4
+ * `1` for the key pointing where the line runs, `-1` for the one pointing back
5
+ * to where it started, `0` for any other key. On a left-to-right page that is
6
+ * `ArrowRight` and `ArrowLeft`; on a right-to-left page it is the other way
7
+ * round, because a row of tabs, a toolbar or a week of days is laid out from
8
+ * the right there and an arrow is a promise about where on screen focus goes.
9
+ *
10
+ * **The computed `direction`, not `:dir()` or the `dir` attribute.** The two
11
+ * part only where a sheet sets `direction` without `dir`, and there the row is
12
+ * laid out by `direction`: a flex row and a grid both follow it. What a reader
13
+ * sees is the order the key has to agree with, and it is the question the
14
+ * table's and the splitter's pointer arithmetic already ask.
15
+ *
16
+ * Asked at the press rather than once, so a page that changes `dir` is
17
+ * answered in its new direction without anything having to watch it.
18
+ *
19
+ * Up and down are not asked about: a block runs down the page in every script
20
+ * here, so a vertical key means the same thing whichever way the line runs.
21
+ */
22
+ export declare const inlineStep: (element: Element, key: string) => -1 | 0 | 1;
@@ -0,0 +1,7 @@
1
+ export const inlineStep = (element, key) => {
2
+ if (key !== 'ArrowLeft' && key !== 'ArrowRight') {
3
+ return 0;
4
+ }
5
+ const rtl = getComputedStyle(element).direction === 'rtl';
6
+ return (key === 'ArrowRight') !== rtl ? 1 : -1;
7
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-core",
3
3
  "description": "Helix element primitives: base classes, mixins, decorators, utilities.",
4
- "version": "0.32.0",
4
+ "version": "0.33.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -34,12 +34,12 @@
34
34
  "bugs": "https://github.com/project-fusion/fusion/issues",
35
35
  "dependencies": {
36
36
  "@floating-ui/dom": "^1.8.0",
37
- "@fusion.dev/helix-icons": "0.32.0"
37
+ "@fusion.dev/helix-icons": "0.33.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@custom-elements-manifest/analyzer": "^0.11.0",
41
- "@fusion.dev/helix-cem": "0.32.0",
42
- "@fusion.dev/helix-testing": "0.32.0",
41
+ "@fusion.dev/helix-cem": "0.33.0",
42
+ "@fusion.dev/helix-testing": "0.33.0",
43
43
  "lit": "^3.2.1"
44
44
  },
45
45
  "homepage": "https://fusion.dev",