@fusion.dev/helix-core 0.0.0-dev

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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/custom-elements.json +8005 -0
  3. package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +67 -0
  4. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +146 -0
  5. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +79 -0
  6. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +140 -0
  7. package/dist/controllers/command-controller/command-controller.d.ts +83 -0
  8. package/dist/controllers/command-controller/command-controller.js +93 -0
  9. package/dist/controllers/copy-controller/copy-controller.d.ts +58 -0
  10. package/dist/controllers/copy-controller/copy-controller.js +128 -0
  11. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.d.ts +76 -0
  12. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +67 -0
  13. package/dist/controllers/dismiss-controller/dismiss-controller.d.ts +58 -0
  14. package/dist/controllers/dismiss-controller/dismiss-controller.js +165 -0
  15. package/dist/controllers/drilldown-controller/drilldown-controller.d.ts +127 -0
  16. package/dist/controllers/drilldown-controller/drilldown-controller.js +171 -0
  17. package/dist/controllers/floating-controller/floating-controller.d.ts +123 -0
  18. package/dist/controllers/floating-controller/floating-controller.js +227 -0
  19. package/dist/controllers/focus-trap-controller/focus-trap-controller.d.ts +26 -0
  20. package/dist/controllers/focus-trap-controller/focus-trap-controller.js +93 -0
  21. package/dist/controllers/indicator-controller/indicator-controller.d.ts +97 -0
  22. package/dist/controllers/indicator-controller/indicator-controller.js +164 -0
  23. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +113 -0
  24. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +117 -0
  25. package/dist/controllers/popover-controller/popover-controller.d.ts +132 -0
  26. package/dist/controllers/popover-controller/popover-controller.js +150 -0
  27. package/dist/controllers/source-controller/source-controller.d.ts +55 -0
  28. package/dist/controllers/source-controller/source-controller.js +94 -0
  29. package/dist/controllers/token-color-controller/token-color-controller.d.ts +63 -0
  30. package/dist/controllers/token-color-controller/token-color-controller.js +105 -0
  31. package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +127 -0
  32. package/dist/controllers/virtualize-controller/virtualize-controller.js +216 -0
  33. package/dist/events/element-events.d.ts +4 -0
  34. package/dist/events/element-events.js +34 -0
  35. package/dist/events/events.d.ts +743 -0
  36. package/dist/events/events.js +799 -0
  37. package/dist/helix-element/helix-element.d.ts +201 -0
  38. package/dist/helix-element/helix-element.js +695 -0
  39. package/dist/helix-form-element/helix-form-element.d.ts +152 -0
  40. package/dist/helix-form-element/helix-form-element.js +194 -0
  41. package/dist/index.d.ts +54 -0
  42. package/dist/index.js +54 -0
  43. package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +137 -0
  44. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +140 -0
  45. package/dist/mixins/mixin-collection/index.d.ts +1 -0
  46. package/dist/mixins/mixin-collection/index.js +1 -0
  47. package/dist/mixins/mixin-collection/mixin-collection.d.ts +58 -0
  48. package/dist/mixins/mixin-collection/mixin-collection.js +154 -0
  49. package/dist/mixins/mixin-controller-active/mixin-controller-active.d.ts +53 -0
  50. package/dist/mixins/mixin-controller-active/mixin-controller-active.js +155 -0
  51. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.d.ts +54 -0
  52. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +157 -0
  53. package/dist/mixins/mixin-controller-open/mixin-controller-open.d.ts +49 -0
  54. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +176 -0
  55. package/dist/mixins/mixin-controller-value/mixin-controller-value.d.ts +73 -0
  56. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +173 -0
  57. package/dist/mixins/mixin-dismissible/mixin-dismissible.d.ts +41 -0
  58. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +79 -0
  59. package/dist/mixins/mixin-field/mixin-field.d.ts +145 -0
  60. package/dist/mixins/mixin-field/mixin-field.js +423 -0
  61. package/dist/utils/anchor-element/anchor-element.d.ts +23 -0
  62. package/dist/utils/anchor-element/anchor-element.js +39 -0
  63. package/dist/utils/capture-pointer/capture-pointer.d.ts +22 -0
  64. package/dist/utils/capture-pointer/capture-pointer.js +30 -0
  65. package/dist/utils/closest-instance/closest-instance.d.ts +25 -0
  66. package/dist/utils/closest-instance/closest-instance.js +34 -0
  67. package/dist/utils/create-sheet/create-sheet.d.ts +16 -0
  68. package/dist/utils/create-sheet/create-sheet.js +16 -0
  69. package/dist/utils/custom-element/custom-element.d.ts +26 -0
  70. package/dist/utils/custom-element/custom-element.js +114 -0
  71. package/dist/utils/draw-icon/draw-icon.d.ts +38 -0
  72. package/dist/utils/draw-icon/draw-icon.js +39 -0
  73. package/dist/utils/draw-tooltip/draw-tooltip.d.ts +56 -0
  74. package/dist/utils/draw-tooltip/draw-tooltip.js +89 -0
  75. package/dist/utils/elapsed-time/elapsed-time.d.ts +10 -0
  76. package/dist/utils/elapsed-time/elapsed-time.js +117 -0
  77. package/dist/utils/focusable-elements/focusable-elements.d.ts +40 -0
  78. package/dist/utils/focusable-elements/focusable-elements.js +88 -0
  79. package/dist/utils/format-numbers/format-numbers.d.ts +1 -0
  80. package/dist/utils/format-numbers/format-numbers.js +29 -0
  81. package/dist/utils/host-aria/host-aria.d.ts +38 -0
  82. package/dist/utils/host-aria/host-aria.js +41 -0
  83. package/dist/utils/inherit-disabled/inherit-disabled.d.ts +33 -0
  84. package/dist/utils/inherit-disabled/inherit-disabled.js +55 -0
  85. package/dist/utils/line-path/line-path.d.ts +23 -0
  86. package/dist/utils/line-path/line-path.js +107 -0
  87. package/dist/utils/on-unless-false/on-unless-false.d.ts +28 -0
  88. package/dist/utils/on-unless-false/on-unless-false.js +32 -0
  89. package/dist/utils/read-series/read-series.d.ts +78 -0
  90. package/dist/utils/read-series/read-series.js +187 -0
  91. package/dist/utils/relative-time/relative-time.d.ts +24 -0
  92. package/dist/utils/relative-time/relative-time.js +84 -0
  93. package/dist/utils/request-change/request-change.d.ts +48 -0
  94. package/dist/utils/request-change/request-change.js +7 -0
  95. package/dist/utils/resolve-active-item/resolve-active-item.d.ts +28 -0
  96. package/dist/utils/resolve-active-item/resolve-active-item.js +21 -0
  97. package/dist/utils/restore-value/restore-value.d.ts +32 -0
  98. package/dist/utils/restore-value/restore-value.js +84 -0
  99. package/dist/utils/scale/scale.d.ts +23 -0
  100. package/dist/utils/scale/scale.js +32 -0
  101. package/dist/utils/scale/ticks.d.ts +27 -0
  102. package/dist/utils/scale/ticks.js +52 -0
  103. package/dist/utils/scroll-item-into-view/scroll-item-into-view.d.ts +10 -0
  104. package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +98 -0
  105. package/dist/utils/slot-filled/slot-filled.d.ts +26 -0
  106. package/dist/utils/slot-filled/slot-filled.js +26 -0
  107. package/dist/utils/tell-disclosure/tell-disclosure.d.ts +51 -0
  108. package/dist/utils/tell-disclosure/tell-disclosure.js +76 -0
  109. package/dist/utils/ticker/ticker.d.ts +8 -0
  110. package/dist/utils/ticker/ticker.js +55 -0
  111. package/dist/utils/tone-marks/tone-marks.d.ts +52 -0
  112. package/dist/utils/tone-marks/tone-marks.js +36 -0
  113. package/dist/utils/value-as/value-as.d.ts +64 -0
  114. package/dist/utils/value-as/value-as.js +105 -0
  115. package/package.json +58 -0
@@ -0,0 +1,97 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ /**
3
+ * What an indicator needs to know, asked for rather than assumed.
4
+ */
5
+ export interface IndicatorOptions {
6
+ /** The element that is currently marked, or nothing if none is. */
7
+ active: () => HTMLElement | null | undefined;
8
+ /** The traveling mark. Positioned against the surface. */
9
+ indicator: () => HTMLElement | null | undefined;
10
+ /**
11
+ * The box the mark is positioned against, which is its containing block.
12
+ *
13
+ * Not the host. An absolutely positioned element is placed against its
14
+ * containing block's *padding* box, so this has to be whichever element
15
+ * carries `position: relative`, and that is usually a part rather than the
16
+ * host itself.
17
+ */
18
+ surface: () => HTMLElement | null | undefined;
19
+ }
20
+ /**
21
+ * Measures the marked child and writes the geometry onto a traveling mark.
22
+ *
23
+ * **Four components had written this and the copies had diverged.** `Navigation`,
24
+ * `Tabs`, `Toggle.Group` and `Outline` each measured an active child and wrote
25
+ * a position onto an indicator, and two of them had bugs the others did not.
26
+ * `Outline`'s own comment counted the repetition rather than resolving it, and
27
+ * the audit found the same shape independently in all four.
28
+ *
29
+ * What the copies disagreed about, and what this settles:
30
+ *
31
+ * **The inline axis is signed.** In a right-to-left document the distance is
32
+ * measured from the surface's right edge and applied as a negative translation,
33
+ * because the mark travels from the inline start and the inline start is on the
34
+ * right. `Navigation` measured from the physical left and applied it from the
35
+ * inline-start edge with an unsigned offset, so its marker went the wrong way
36
+ * in RTL. That is the bug this extraction exists to stop repeating.
37
+ *
38
+ * **The border has to come off both axes.** `getBoundingClientRect` gives the
39
+ * border box and the containing block is the padding box, so a surface with a
40
+ * border of its own puts the mark one border-width out. `Tabs` and
41
+ * `Toggle.Group` each did this arithmetic separately, `Outline` did the block
42
+ * half with `clientTop`, and `Navigation` did neither. `clientLeft` and `clientTop`
43
+ * are the border widths, already resolved, which is why they are used here
44
+ * rather than parsing `borderLeftWidth`.
45
+ *
46
+ * **Nothing is written until there is something to measure.** A zero-width box
47
+ * is a component whose children have not upgraded yet, and writing zeroes would
48
+ * park the mark at the origin and leave it there. The `data-placed` attribute is
49
+ * what a stylesheet waits for, so a mark fades in where it belongs rather than
50
+ * sliding from the corner on first paint.
51
+ *
52
+ * The four custom properties are the contract. What a component does with them
53
+ * is its own business, which is how one controller serves a bar under a tab, a
54
+ * pill filling it, and a rule down the side of a contents page.
55
+ *
56
+ * **Declared here because this is what writes them.** They carry a measurement
57
+ * no stylesheet can take, they are read by whoever draws the mark, and `core`
58
+ * ships no CSS, so there is no reader inside this package and nothing about
59
+ * them is private. `check:hooks` pairs a `@cssproperty` with a rule that reads
60
+ * one, in both directions, and the underscore these used to carry was what
61
+ * exempted them from it.
62
+ *
63
+ * @cssproperty --indicator-block-size - How tall the active item is, in pixels.
64
+ * @cssproperty --indicator-inline-size - How wide the active item is, in pixels.
65
+ * @cssproperty --indicator-x - Where the active item starts, along the inline axis.
66
+ * @cssproperty --indicator-y - Where the active item starts, along the block axis.
67
+ */
68
+ export declare class IndicatorController implements ReactiveController {
69
+ private readonly host;
70
+ private readonly options;
71
+ private resize;
72
+ constructor(host: ReactiveControllerHost & HTMLElement, options: IndicatorOptions);
73
+ hostConnected(): void;
74
+ hostDisconnected(): void;
75
+ /** Called after every update, which is when the marked child may have moved. */
76
+ hostUpdated(): void;
77
+ /** Where the mark was last put, so a placement can tell a move from a resize. */
78
+ private last;
79
+ /** The guard behind `transitionend`, in case it never comes. */
80
+ private settling;
81
+ /**
82
+ * The listener currently waiting, so the next move can take it off first.
83
+ *
84
+ * **Without this, a move inside a second orphans a listener.** Each placement
85
+ * built a fresh `settle` closure and then called `clearTimeout` on the timer
86
+ * belonging to the *previous* one, which is the only thing that would have
87
+ * removed it: the event itself never arrives under reduced motion, where
88
+ * there is no transition to end. So a reader changing tabs quickly, or any
89
+ * reader at all with motion reduced, left one dead listener per selection on
90
+ * an element that lives as long as the page. The people who asked for less
91
+ * work were the ones accumulating it.
92
+ */
93
+ private waiting;
94
+ place(): void;
95
+ /** Takes the pending listener and its guard off, wherever they came from. */
96
+ private stopWaiting;
97
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Measures the marked child and writes the geometry onto a traveling mark.
3
+ *
4
+ * **Four components had written this and the copies had diverged.** `Navigation`,
5
+ * `Tabs`, `Toggle.Group` and `Outline` each measured an active child and wrote
6
+ * a position onto an indicator, and two of them had bugs the others did not.
7
+ * `Outline`'s own comment counted the repetition rather than resolving it, and
8
+ * the audit found the same shape independently in all four.
9
+ *
10
+ * What the copies disagreed about, and what this settles:
11
+ *
12
+ * **The inline axis is signed.** In a right-to-left document the distance is
13
+ * measured from the surface's right edge and applied as a negative translation,
14
+ * because the mark travels from the inline start and the inline start is on the
15
+ * right. `Navigation` measured from the physical left and applied it from the
16
+ * inline-start edge with an unsigned offset, so its marker went the wrong way
17
+ * in RTL. That is the bug this extraction exists to stop repeating.
18
+ *
19
+ * **The border has to come off both axes.** `getBoundingClientRect` gives the
20
+ * border box and the containing block is the padding box, so a surface with a
21
+ * border of its own puts the mark one border-width out. `Tabs` and
22
+ * `Toggle.Group` each did this arithmetic separately, `Outline` did the block
23
+ * half with `clientTop`, and `Navigation` did neither. `clientLeft` and `clientTop`
24
+ * are the border widths, already resolved, which is why they are used here
25
+ * rather than parsing `borderLeftWidth`.
26
+ *
27
+ * **Nothing is written until there is something to measure.** A zero-width box
28
+ * is a component whose children have not upgraded yet, and writing zeroes would
29
+ * park the mark at the origin and leave it there. The `data-placed` attribute is
30
+ * what a stylesheet waits for, so a mark fades in where it belongs rather than
31
+ * sliding from the corner on first paint.
32
+ *
33
+ * The four custom properties are the contract. What a component does with them
34
+ * is its own business, which is how one controller serves a bar under a tab, a
35
+ * pill filling it, and a rule down the side of a contents page.
36
+ *
37
+ * **Declared here because this is what writes them.** They carry a measurement
38
+ * no stylesheet can take, they are read by whoever draws the mark, and `core`
39
+ * ships no CSS, so there is no reader inside this package and nothing about
40
+ * them is private. `check:hooks` pairs a `@cssproperty` with a rule that reads
41
+ * one, in both directions, and the underscore these used to carry was what
42
+ * exempted them from it.
43
+ *
44
+ * @cssproperty --indicator-block-size - How tall the active item is, in pixels.
45
+ * @cssproperty --indicator-inline-size - How wide the active item is, in pixels.
46
+ * @cssproperty --indicator-x - Where the active item starts, along the inline axis.
47
+ * @cssproperty --indicator-y - Where the active item starts, along the block axis.
48
+ */
49
+ export class IndicatorController {
50
+ constructor(host, options) {
51
+ this.host = host;
52
+ this.options = options;
53
+ host.addController(this);
54
+ }
55
+ hostConnected() {
56
+ /* The host's own size is not enough: a strip that keeps its width while its
57
+ children rewrap moves every one of them, and a mark measured against the
58
+ old layout is left behind. Observing the host catches both, because a
59
+ rewrap changes its height. */
60
+ this.resize = new ResizeObserver(() => this.place());
61
+ this.resize.observe(this.host);
62
+ }
63
+ hostDisconnected() {
64
+ this.resize?.disconnect();
65
+ this.resize = undefined;
66
+ /* The host going away is the third way a move can end, and the one nothing
67
+ was watching. A timer holding a closure over a detached element keeps it
68
+ alive for the second it has left to run. */
69
+ this.stopWaiting();
70
+ }
71
+ /** Called after every update, which is when the marked child may have moved. */
72
+ hostUpdated() {
73
+ this.place();
74
+ }
75
+ /*
76
+ * Measures and writes, or gives up without writing anything.
77
+ *
78
+ * Public because `slotchange` is not an update: children arriving is exactly
79
+ * when the first measurement becomes possible, and a component that only
80
+ * placed on its own updates left the mark unplaced for the life of the page.
81
+ */
82
+ place() {
83
+ const indicator = this.options.indicator();
84
+ const surface = this.options.surface();
85
+ const active = this.options.active();
86
+ if (!indicator || !surface) {
87
+ return;
88
+ }
89
+ if (!active) {
90
+ // Nothing is marked, so there is nothing to point at. The attribute goes
91
+ // and the stylesheet takes the mark away with it.
92
+ indicator.removeAttribute('data-placed');
93
+ return;
94
+ }
95
+ const box = active.getBoundingClientRect();
96
+ const own = surface.getBoundingClientRect();
97
+ if (box.width === 0 && box.height === 0) {
98
+ return;
99
+ }
100
+ /* `clientLeft` and `clientTop` are the resolved border widths, which is the
101
+ difference between the border box the measurement gives and the padding
102
+ box the mark is placed against. */
103
+ const rtl = getComputedStyle(surface).direction === 'rtl';
104
+ const inline = rtl
105
+ ? own.right - box.right - surface.clientLeft
106
+ : box.left - own.left - surface.clientLeft;
107
+ const x = `${rtl ? -inline : inline}px`;
108
+ const y = `${box.top - own.top - surface.clientTop}px`;
109
+ /* `data-moving` while it is on its way, so a stylesheet can say something
110
+ * about the travel itself.
111
+ *
112
+ * There is no CSS state for "a transition is running", and the effect this
113
+ * exists for wants one: a mark that swells as it leaves and settles back
114
+ * when it lands reads as a thing being carried rather than a rectangle
115
+ * being repositioned. The controller is the only thing that knows a move
116
+ * has been asked for, so it says so.
117
+ *
118
+ * Only on a *change*, and never on the first placement: the mark arrives by
119
+ * fading in, and swelling on arrival would be an animation about nothing.
120
+ *
121
+ * Cleared on `transitionend` with a timeout behind it. The event is the
122
+ * honest signal and it does not always come: a move under reduced motion
123
+ * has no transition to end, and neither does one whose computed position
124
+ * turns out identical after a resize. A marker that can stick is worse than
125
+ * one that clears a little early. */
126
+ const moved = this.last !== undefined && (this.last.x !== x || this.last.y !== y);
127
+ this.last = { x, y };
128
+ indicator.style.setProperty('--indicator-x', x);
129
+ indicator.style.setProperty('--indicator-y', y);
130
+ indicator.style.setProperty('--indicator-inline-size', `${box.width}px`);
131
+ indicator.style.setProperty('--indicator-block-size', `${box.height}px`);
132
+ indicator.toggleAttribute('data-placed', true);
133
+ if (!moved) {
134
+ return;
135
+ }
136
+ indicator.toggleAttribute('data-moving', true);
137
+ this.stopWaiting();
138
+ /* `translate` only. `transitionend` fires once per property, and a
139
+ stylesheet reacting to `data-moving` is very likely to transition
140
+ something shorter than the travel: the outline's mark widens over
141
+ `duration-fast` while it travels over `duration-normal`, so an unfiltered
142
+ listener had the width's own transition end the move that caused it, a
143
+ third of the way through. */
144
+ const settle = (event) => {
145
+ if (event && event.propertyName !== 'translate') {
146
+ return;
147
+ }
148
+ indicator.removeAttribute('data-moving');
149
+ this.stopWaiting();
150
+ };
151
+ indicator.addEventListener('transitionend', settle);
152
+ this.waiting = { on: indicator, settle };
153
+ this.settling = setTimeout(settle, 1000);
154
+ }
155
+ /** Takes the pending listener and its guard off, wherever they came from. */
156
+ stopWaiting() {
157
+ clearTimeout(this.settling);
158
+ this.settling = undefined;
159
+ if (this.waiting) {
160
+ this.waiting.on.removeEventListener('transitionend', this.waiting.settle);
161
+ this.waiting = undefined;
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,113 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ /** What a controller needs of an item to move to it and choose it. */
3
+ export interface NavigableItem {
4
+ /**
5
+ * Whether the press came from inside this item, for `origin: 'focus'`.
6
+ *
7
+ * Every item that uses this is an element, so this is `Node.contains` and
8
+ * nothing has to implement it. It is declared because the controller depends
9
+ * on it, and a structural interface that leaves out what it calls is a
10
+ * `NavigableItem` a component can satisfy and still break.
11
+ */
12
+ contains(node: Node | null): boolean;
13
+ /** Whether the item can be reached at all. Skipped when it cannot. */
14
+ disabled: boolean;
15
+ /** Moves focus to the item, which each family does its own way. */
16
+ focusItem(): void;
17
+ /** Chooses the item, which for a radio group is the same act as arriving. */
18
+ select(): void;
19
+ /** Whether the item is the current one, which is where movement starts. */
20
+ selected: boolean;
21
+ }
22
+ /** How the controller is told what it is moving through. */
23
+ export interface KeyboardNavigationOptions {
24
+ /** The items, in the order a reader moves through them. */
25
+ items: () => NavigableItem[];
26
+ /**
27
+ * Where a move starts from.
28
+ *
29
+ * `selection` is the radio-group reading and the default: the set has one
30
+ * answer, focus is always on it, and the two never diverge.
31
+ *
32
+ * `focus` is for a set where they can. Tabs is the case: in `manual` mode a
33
+ * reader arrows without choosing, so after one press focus is no longer on
34
+ * the selection, and starting from the selection walks from the same place
35
+ * every time. It is also the only reading that can tell a press on a tab from
36
+ * a press somewhere else in the component, which matters wherever the host
37
+ * holds more than the set (a tab panel is a tab stop of its own).
38
+ */
39
+ origin?: 'focus' | 'selection';
40
+ /**
41
+ * Whether arriving also chooses.
42
+ *
43
+ * A function rather than a flag: Tabs takes it from `manual`, which a
44
+ * consumer can change at any time, and a value read once in the constructor
45
+ * would be the value the element happened to have when it upgraded.
46
+ */
47
+ selects?: () => boolean;
48
+ /**
49
+ * Keys this component binds to something else, which this must not take.
50
+ *
51
+ * **A claim, not a preference.** "Both axes, always" is argued below and the
52
+ * argument holds: a reader who guesses the wrong axis and gets nothing is a
53
+ * reader who concludes the set does not answer the keyboard. So this is not a
54
+ * way to support fewer keys. It is a way to say that a key already means
55
+ * something here, and a component that names one owes that key a binding of
56
+ * its own.
57
+ *
58
+ * `Menu` is the case it was written for. Its rows can open a submenu, and the
59
+ * platform's own bindings for that are the horizontal pair: right goes in,
60
+ * left comes back. Those keys are not unhandled in a menu, they are spoken
61
+ * for, and without this the controller would take them first and a submenu
62
+ * could not be opened from the keyboard at all.
63
+ */
64
+ reserved?: () => string[];
65
+ /**
66
+ * Whether the ends join up. Radio groups wrap; a list of commands does not.
67
+ */
68
+ wrap?: boolean;
69
+ }
70
+ /**
71
+ * Moves through a set of items with the arrow keys, and chooses as it goes.
72
+ *
73
+ * **Moving and choosing are one act here, not two.** That is the radio group
74
+ * pattern and it is the difference from a listbox: moving focus without
75
+ * choosing leaves a keyboard reader sitting on an item they have not selected,
76
+ * with nothing on screen saying which of the two is true. A pointer user
77
+ * chooses by arriving, so a keyboard user does too.
78
+ *
79
+ * **Both axes, always.** A vertical set still answers left and right, and a
80
+ * horizontal one still answers up and down. Binding only the axis the items
81
+ * happen to be laid out on means a reader who guesses wrong gets nothing, and
82
+ * `orientation` is presentation: the same set of choices reads the same way
83
+ * whichever direction it runs.
84
+ *
85
+ * Disabled items are skipped rather than stopped at, so a set with a gap in it
86
+ * still moves in one press.
87
+ *
88
+ * Written because `Radio.Group` and `Toggle.Group` had this twice, identically,
89
+ * down to the order of the branches. The second copy is what makes it a
90
+ * controller: one is a component's own business, two is a pattern, and the two
91
+ * had not diverged only because nothing had touched them yet.
92
+ *
93
+ * **`Tabs` is the third, and it is what `origin` and `selects` are for.** It had
94
+ * diverged, which is the case the paragraph above was worried about: it started
95
+ * from focus rather than from the selection, and it moved without choosing when
96
+ * `manual` was set. Both are real and neither is a tabs-only idea, so they are
97
+ * options here rather than a reason to keep a fourth copy. What it does *not*
98
+ * keep is its own key set: it bound the horizontal pair only, and both axes is
99
+ * the house position, argued two paragraphs up. A reader on a tab who presses
100
+ * Down now moves along the row instead of getting nothing.
101
+ */
102
+ export declare class KeyboardNavigationController implements ReactiveController {
103
+ private readonly host;
104
+ private readonly items;
105
+ private readonly origin;
106
+ private readonly selects;
107
+ private readonly reserved;
108
+ private readonly wrap;
109
+ constructor(host: ReactiveControllerHost & HTMLElement, { items, origin, reserved, selects, wrap, }: KeyboardNavigationOptions);
110
+ private readonly handleKeydown;
111
+ hostConnected(): void;
112
+ hostDisconnected(): void;
113
+ }
@@ -0,0 +1,117 @@
1
+ /** Every key that moves within a set, which is the whole of what this handles. */
2
+ const KEYS = new Set(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home']);
3
+ /**
4
+ * Moves through a set of items with the arrow keys, and chooses as it goes.
5
+ *
6
+ * **Moving and choosing are one act here, not two.** That is the radio group
7
+ * pattern and it is the difference from a listbox: moving focus without
8
+ * choosing leaves a keyboard reader sitting on an item they have not selected,
9
+ * with nothing on screen saying which of the two is true. A pointer user
10
+ * chooses by arriving, so a keyboard user does too.
11
+ *
12
+ * **Both axes, always.** A vertical set still answers left and right, and a
13
+ * horizontal one still answers up and down. Binding only the axis the items
14
+ * happen to be laid out on means a reader who guesses wrong gets nothing, and
15
+ * `orientation` is presentation: the same set of choices reads the same way
16
+ * whichever direction it runs.
17
+ *
18
+ * Disabled items are skipped rather than stopped at, so a set with a gap in it
19
+ * still moves in one press.
20
+ *
21
+ * Written because `Radio.Group` and `Toggle.Group` had this twice, identically,
22
+ * down to the order of the branches. The second copy is what makes it a
23
+ * controller: one is a component's own business, two is a pattern, and the two
24
+ * had not diverged only because nothing had touched them yet.
25
+ *
26
+ * **`Tabs` is the third, and it is what `origin` and `selects` are for.** It had
27
+ * diverged, which is the case the paragraph above was worried about: it started
28
+ * from focus rather than from the selection, and it moved without choosing when
29
+ * `manual` was set. Both are real and neither is a tabs-only idea, so they are
30
+ * options here rather than a reason to keep a fourth copy. What it does *not*
31
+ * keep is its own key set: it bound the horizontal pair only, and both axes is
32
+ * the house position, argued two paragraphs up. A reader on a tab who presses
33
+ * Down now moves along the row instead of getting nothing.
34
+ */
35
+ export class KeyboardNavigationController {
36
+ constructor(host, { items, origin = 'selection', reserved = () => [], selects = () => true, wrap = true, }) {
37
+ this.handleKeydown = (event) => {
38
+ if (!KEYS.has(event.key) || this.reserved().includes(event.key)) {
39
+ return;
40
+ }
41
+ const items = this.items().filter((item) => !item.disabled);
42
+ if (items.length === 0) {
43
+ return;
44
+ }
45
+ /* `-1` means two different things and only one of them is a starting point.
46
+ *
47
+ Starting from the selection, `-1` is "nothing is chosen", and the
48
+ arithmetic below relies on it: a forward step lands on the first item and
49
+ a backward step on the last, both of which are where a reader entering an
50
+ unanswered set expects to arrive.
51
+
52
+ Starting from focus, `-1` is "the press did not come from the set", and
53
+ there is nothing to move. The listener is on the host, not on the items,
54
+ and a host can hold more than the set: a tab panel is `tabindex="0"`, so
55
+ an arrow pressed while reading a panel used to move the tabs out from
56
+ under the reader. */
57
+ const current = this.origin === 'focus'
58
+ ? items.findIndex((item) => item.contains(event.target))
59
+ : items.findIndex((item) => item.selected);
60
+ if (this.origin === 'focus' && current === -1) {
61
+ return;
62
+ }
63
+ const last = items.length - 1;
64
+ let next = current;
65
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
66
+ next = this.wrap ? (current + 1) % items.length : Math.min(current + 1, last);
67
+ }
68
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
69
+ /* From nothing, backwards is the last, which the arithmetic did not give.
70
+ *
71
+ * `(-1 - 1 + length) % length` is `length - 2`: the *second* to last. So a
72
+ * reader arrowing backwards into an unanswered group landed one short of
73
+ * the end, and the comment above says plainly that it lands on the end.
74
+ * The wrapping form is right for every real index and wrong for the one
75
+ * that is not an index.
76
+ *
77
+ * And the clamp is `Math.max` against the forward branch's `Math.min`,
78
+ * which it was missing. Unclamped this is `-1` on the first item, and
79
+ * `items[-1]` is undefined, so nothing was focused and nothing chosen on a
80
+ * press that `preventDefault` had already swallowed. Holding Left on the
81
+ * first star of a rating got silence, where holding Right on the last one
82
+ * holds its ground. */
83
+ next =
84
+ current < 0
85
+ ? last
86
+ : this.wrap
87
+ ? (current - 1 + items.length) % items.length
88
+ : Math.max(current - 1, 0);
89
+ }
90
+ else if (event.key === 'Home') {
91
+ next = 0;
92
+ }
93
+ else {
94
+ next = last;
95
+ }
96
+ event.preventDefault();
97
+ const target = items[next];
98
+ target?.focusItem();
99
+ if (this.selects()) {
100
+ target?.select();
101
+ }
102
+ };
103
+ this.host = host;
104
+ this.items = items;
105
+ this.origin = origin;
106
+ this.reserved = reserved;
107
+ this.selects = selects;
108
+ this.wrap = wrap;
109
+ host.addController(this);
110
+ }
111
+ hostConnected() {
112
+ this.host.addEventListener('keydown', this.handleKeydown);
113
+ }
114
+ hostDisconnected() {
115
+ this.host.removeEventListener('keydown', this.handleKeydown);
116
+ }
117
+ }
@@ -0,0 +1,132 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ import { type Placement } from '../floating-controller/floating-controller.js';
3
+ export interface PopoverOptions {
4
+ /** The element the surface opens against. */
5
+ anchor: () => HTMLElement | null | undefined;
6
+ /**
7
+ * The element that points at the anchor, where the surface has one.
8
+ *
9
+ * Passed through to the floating pass, which positions it against whichever
10
+ * side the surface ended up on. Optional because most surfaces have none: a
11
+ * menu and a select are read as belonging to the control they hang off, and
12
+ * only a bubble has to say which thing it is describing.
13
+ */
14
+ arrow?: () => HTMLElement | null | undefined;
15
+ /**
16
+ * What counts as inside for the purpose of a press landing elsewhere.
17
+ *
18
+ * Defaults to the surface. A host that draws no box of its own points this at
19
+ * itself instead, so a press on its trigger is inside rather than out.
20
+ */
21
+ boundary?: () => Element | readonly (Element | null | undefined)[] | null | undefined;
22
+ /** Space between the anchor and the surface, in pixels. */
23
+ offset?: number | (() => number);
24
+ /**
25
+ * A box to keep the surface inside, instead of the viewport.
26
+ *
27
+ * Not `boundary` above, and the two are easy to confuse: that one decides
28
+ * what counts as inside for a *press*, and this one decides what counts as
29
+ * inside for the *placement*. A tooltip on a chart wants the second and has
30
+ * no use for the first.
31
+ */
32
+ within?: () => Element | null | undefined;
33
+ /**
34
+ * Whether to measure the anchor and place the surface against it.
35
+ *
36
+ * On unless said otherwise, which is every surface that hangs off something:
37
+ * a menu, a select, a tooltip. Off is for a surface whose position is a fact
38
+ * about the page rather than about its trigger, and the one that asked for
39
+ * it is a full-bleed navigation panel: it spans the viewport and sits under
40
+ * the header, so there is nothing to measure and nothing to flip or shift
41
+ * to. Placing it anyway wrote a `translate` of the trigger's own offset over
42
+ * a band that is meant to start at the edge.
43
+ *
44
+ * The rest still applies, which is why this is an option here rather than a
45
+ * reason to do without the controller: the top layer and the dismissal are
46
+ * exactly what such a panel needs, and they are what this bundles. What it
47
+ * gives up is the measuring pass, and the surface's own stylesheet says where
48
+ * it goes.
49
+ */
50
+ place?: boolean | (() => boolean);
51
+ /**
52
+ * Called when a press outside or an Escape asks for it to close.
53
+ *
54
+ * The host decides what closing means, because it is the only thing that
55
+ * knows what else has to happen: a menu forgets which page it was on, a
56
+ * select puts focus back on its trigger, and a tooltip does neither.
57
+ */
58
+ onDismiss: () => void;
59
+ /** Preferred side. It moves if that side does not fit. */
60
+ placement?: Placement | (() => Placement);
61
+ /** The panel that floats. */
62
+ surface: () => HTMLElement | null | undefined;
63
+ }
64
+ /**
65
+ * A surface that floats against something, in the top layer, until dismissed.
66
+ *
67
+ * Three things always travel together and were written out longhand three
68
+ * times: the popover API, a `FloatingController` and a `DismissController`.
69
+ * Individually each is small, which is exactly why the copies drifted rather
70
+ * than being noticed. **`Select` never called `showPopover` at all**, so its
71
+ * list was an ordinary positioned panel and any ancestor with `overflow:
72
+ * hidden` clipped it, while a menu two folders away in the same position did
73
+ * not. That is not a difference anyone chose.
74
+ *
75
+ * **The top layer is the whole point.** A floating panel has to escape every
76
+ * clipping ancestor between it and the page, and `position: fixed` does not do
77
+ * that: it is clipped by `overflow` on any ancestor and re-anchored entirely by
78
+ * an ancestor carrying `transform`, `filter` or `contain`. The popover API
79
+ * lifts the element out of the tree's paint order for real, and it is the only
80
+ * thing that does.
81
+ *
82
+ * `manual` rather than `auto`, because the host owns dismissal. `auto`
83
+ * popovers light-dismiss themselves and close each other, which is close to
84
+ * what is wanted and not the same: a select that closed itself before its own
85
+ * change event landed, or a submenu that closed its parent, are both things the
86
+ * platform would do here and neither is right.
87
+ *
88
+ * **The order matters on the way up.** `showPopover` first, then position:
89
+ * measuring an element that is still `display: none` gives zeroes, so a surface
90
+ * positioned before it is shown lands at the origin and slides into place on
91
+ * the next frame. On the way down it is the reverse, and stopping the observer
92
+ * before hiding avoids a final measurement of an element on its way out.
93
+ *
94
+ * What this does not own is *when*. Opening is the host's, because a menu opens
95
+ * on click, a tooltip on hover and a select on either, and none of that is
96
+ * about surfaces.
97
+ */
98
+ export declare class PopoverController implements ReactiveController {
99
+ private readonly options;
100
+ private readonly floating;
101
+ private readonly dismisser;
102
+ /** Whether the surface is currently shown, so the calls can be idempotent. */
103
+ private shown;
104
+ constructor(host: ReactiveControllerHost & HTMLElement, options: PopoverOptions);
105
+ /** Whether the surface is showing. */
106
+ get open(): boolean;
107
+ /** Whether this one is placed by measurement, read fresh so it may change. */
108
+ private get places();
109
+ /**
110
+ * Shows the surface, positions it, and starts listening for a way out.
111
+ *
112
+ * Safe to call when already open, so a host may call it from whatever
113
+ * combination of a property, an event and a command it likes without keeping
114
+ * track of which one got there first.
115
+ */
116
+ show(): void;
117
+ /** Hides the surface and stops everything watching it. */
118
+ hide(): void;
119
+ /** Whichever of the two the surface is not already doing. */
120
+ toggle(): void;
121
+ /**
122
+ * Places the surface again, for an anchor that moved without the page moving.
123
+ *
124
+ * `autoUpdate` watches scrolling, resizing and layout shift, which covers
125
+ * everything that moves an anchor *with* its surroundings. It does not cover
126
+ * an absolutely positioned anchor being moved by an inline style, and that is
127
+ * exactly what a chart does: the mark walks from reading to reading while the
128
+ * page holds still, so the bubble was placed once and stayed there.
129
+ */
130
+ reposition(): void;
131
+ hostDisconnected(): void;
132
+ }