@fusion.dev/helix-headless 0.2.0 → 0.4.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.
Files changed (127) hide show
  1. package/custom-elements.json +234 -234
  2. package/dist/accordion/item/accordion-item.js +0 -108
  3. package/dist/accordion/root/accordion-root.js +0 -33
  4. package/dist/announcement/root/announcement-root.js +1 -76
  5. package/dist/app-header/root/app-header-root.js +0 -84
  6. package/dist/avatar/group/avatar-group.js +0 -77
  7. package/dist/avatar/root/avatar-root.js +0 -72
  8. package/dist/badge/root/badge-root.js +0 -67
  9. package/dist/bar-chart/root/bar-chart-root.js +3 -263
  10. package/dist/blockquote/root/blockquote-root.js +0 -46
  11. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +0 -72
  12. package/dist/breadcrumbs/root/breadcrumbs-root.js +0 -52
  13. package/dist/button/root/button-root.js +0 -215
  14. package/dist/button-group/root/button-group-root.js +0 -30
  15. package/dist/callout/root/callout-root.js +0 -52
  16. package/dist/card/root/card-root.js +0 -41
  17. package/dist/carousel/root/carousel-root.d.ts +2 -2
  18. package/dist/carousel/root/carousel-root.js +2 -362
  19. package/dist/chat/composer/chat-composer.js +0 -131
  20. package/dist/chat/message/chat-message.js +0 -137
  21. package/dist/chat/root/chat-root.js +0 -193
  22. package/dist/checkbox/root/checkbox-root.js +0 -113
  23. package/dist/checkbox-group/root/checkbox-group-root.js +1 -157
  24. package/dist/code/root/code-root.js +0 -26
  25. package/dist/code-block/root/code-block-root.js +0 -100
  26. package/dist/color-input/root/color-input-root.js +0 -217
  27. package/dist/color-picker/color.js +0 -69
  28. package/dist/color-picker/root/color-picker-root.js +0 -272
  29. package/dist/combobox/root/combobox-root.js +0 -428
  30. package/dist/command/root/command-root.js +0 -177
  31. package/dist/copy-button/root/copy-button-root.js +0 -134
  32. package/dist/data-table/root/data-table-root.js +0 -336
  33. package/dist/data-table/root/order.js +0 -100
  34. package/dist/date-input/root/date-input-root.js +1 -398
  35. package/dist/date-picker/root/date-picker-root.js +0 -358
  36. package/dist/dialog/root/dialog-root.js +0 -189
  37. package/dist/divider/root/divider-root.js +0 -71
  38. package/dist/donut-chart/root/donut-chart-root.js +0 -214
  39. package/dist/drawer/root/drawer-root.js +0 -116
  40. package/dist/drilldown/item/drilldown-item.js +0 -82
  41. package/dist/drilldown/root/drilldown-root.js +0 -91
  42. package/dist/element-events.generated.js +0 -5
  43. package/dist/empty/root/empty-root.js +0 -35
  44. package/dist/file-input/root/file-input-root.js +0 -232
  45. package/dist/format-date/root/format-date-root.js +0 -95
  46. package/dist/heatmap/root/buckets.js +0 -57
  47. package/dist/heatmap/root/heatmap-root.js +0 -313
  48. package/dist/icon/root/icon-root.js +0 -48
  49. package/dist/icon-button/root/icon-button-root.js +0 -30
  50. package/dist/image/root/image-root.js +0 -174
  51. package/dist/index.js +0 -9
  52. package/dist/input/root/input-root.js +0 -228
  53. package/dist/kbd/root/kbd-root.js +0 -117
  54. package/dist/legend/root/legend-root.js +0 -73
  55. package/dist/line-chart/root/line-chart-root.js +0 -172
  56. package/dist/list/item/list-item.js +0 -92
  57. package/dist/list/root/list-root.js +0 -26
  58. package/dist/listbox/root/listbox-root.js +2 -429
  59. package/dist/loader/root/loader-root.js +0 -40
  60. package/dist/megamenu/root/megamenu-root.js +0 -284
  61. package/dist/menu/item/menu-item.js +1 -221
  62. package/dist/menu/root/menu-root.js +1 -171
  63. package/dist/meter/root/meter-root.js +1 -152
  64. package/dist/navigation/root/navigation-root.js +0 -56
  65. package/dist/notifications/notification/notification.js +0 -159
  66. package/dist/notifications/root/notifications-root.js +0 -148
  67. package/dist/number-input/root/number-input-root.js +1 -263
  68. package/dist/option/group/option-group.js +0 -26
  69. package/dist/option/root/option-root.js +0 -157
  70. package/dist/outline/root/outline-root.js +0 -292
  71. package/dist/pagination/root/pagination-root.js +0 -361
  72. package/dist/pagination/size/pagination-size.js +0 -104
  73. package/dist/pagination/summary/pagination-summary.js +0 -90
  74. package/dist/pin-input/root/pin-input-root.js +0 -276
  75. package/dist/popover/root/popover-root.js +0 -103
  76. package/dist/progress/root/progress-root.js +1 -133
  77. package/dist/qr-code/root/qr-code-root.js +0 -96
  78. package/dist/quote/root/quote-root.js +0 -25
  79. package/dist/radio/group/radio-group.js +0 -81
  80. package/dist/radio/root/radio-root.js +0 -85
  81. package/dist/rating/root/rating-root.js +0 -220
  82. package/dist/scroll-area/root/scroll-area-root.js +0 -100
  83. package/dist/select/root/select-root.js +2 -218
  84. package/dist/skeleton/root/skeleton-root.js +0 -20
  85. package/dist/slider/root/slider-root.js +1 -267
  86. package/dist/sparkline/root/sparkline-root.js +3 -164
  87. package/dist/speed-dial/item/speed-dial-item.js +0 -52
  88. package/dist/speed-dial/root/speed-dial-root.js +1 -116
  89. package/dist/splitter/root/splitter-root.js +0 -489
  90. package/dist/stat/group/stat-group.js +0 -18
  91. package/dist/stat/root/stat-root.js +0 -23
  92. package/dist/steps/root/steps-root.js +0 -62
  93. package/dist/steps/step/step.js +0 -131
  94. package/dist/switch/root/switch-root.js +0 -80
  95. package/dist/table/cell/table-cell.js +0 -23
  96. package/dist/table/column/table-column.js +0 -158
  97. package/dist/table/root/table-root.js +0 -82
  98. package/dist/table/row/table-row.js +0 -21
  99. package/dist/tabs/panel/tab-panel.js +0 -49
  100. package/dist/tabs/root/tabs-root.js +0 -98
  101. package/dist/tabs/tab/tab.js +0 -59
  102. package/dist/tag/root/tag-root.js +0 -31
  103. package/dist/textarea/root/textarea-root.js +0 -162
  104. package/dist/time-input/root/time-input-root.js +0 -283
  105. package/dist/time-picker/root/time-picker-root.js +0 -326
  106. package/dist/timeline/item/timeline-item.js +0 -66
  107. package/dist/timeline/root/timeline-root.js +0 -41
  108. package/dist/toast/item/toast-item.js +0 -165
  109. package/dist/toast/root/toast-root.js +0 -51
  110. package/dist/toggle/group/toggle-group.js +0 -150
  111. package/dist/toggle/root/toggle-root.js +0 -78
  112. package/dist/toolbar/root/toolbar-root.js +0 -116
  113. package/dist/tooltip/root/tooltip-root.js +0 -427
  114. package/dist/tour/root/tour-root.js +0 -130
  115. package/dist/tour/stop/tour-stop.js +1 -127
  116. package/dist/tree/item/tree-item.js +0 -198
  117. package/dist/tree/root/tree-root.js +0 -97
  118. package/dist/trend/root/trend-root.js +0 -53
  119. package/dist/video/root/video-root.d.ts +8 -12
  120. package/dist/video/root/video-root.js +5 -756
  121. package/dist/video-playlist/item/video-playlist-item.js +1 -90
  122. package/dist/video-playlist/menu/video-playlist-menu.js +1 -132
  123. package/dist/video-playlist/root/video-playlist-root.js +0 -113
  124. package/dist/virtual-list/root/virtual-list-root.js +0 -105
  125. package/jsx.d.ts +1408 -2576
  126. package/package.json +6 -6
  127. package/vue.d.ts +1 -1
@@ -7,72 +7,10 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { CommandController, customElement, HelixElement, hostAria, MixinControllerOpen, } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
- /**
11
- * @summary A modal window, on the platform's own dialog.
12
- *
13
- * @description
14
- * A real `<dialog>` opened with `showModal()`, not a div with a role and a
15
- * hand-built trap. The platform gives four things that are each a day's work to
16
- * reimplement and easy to get subtly wrong: the top layer, so the dialog is
17
- * above everything regardless of what `z-index` anyone else wrote; a real
18
- * backdrop; Escape; and inert-behind, so the page underneath is not reachable
19
- * by pointer, keyboard, or a screen reader's virtual cursor.
20
- *
21
- * **`open` drives `showModal()`, and every way it can close comes back through
22
- * `requestOpen`.** A native dialog can close from three directions: the
23
- * property, Escape, and a `<form method="dialog">` submit inside it. Left
24
- * alone, two of those would shut the dialog while the consumer's state still
25
- * said open, which is the bug that makes a modal impossible to reopen. Escape
26
- * is intercepted and routed through the request, so `preventDefault()` on the
27
- * `helix-before-close` refuses it; a close that happens anyway is reflected back.
28
- *
29
- * **Nothing here traps focus.** `showModal()` does it, and better: `inert` on
30
- * the rest of the document is something no JavaScript trap can achieve.
31
- * `FocusTrapController` stays for the surfaces that are not modal.
32
- *
33
- * The regions are bare slots rather than wrappers, for the reason the card's
34
- * are: an unassigned `<slot>` is `display: contents` and generates no box, so a
35
- * dialog with no footer has no empty band where one would be.
36
- *
37
- * Opened declaratively with the platform's invoker attributes:
38
- *
39
- * ```html
40
- * <helix-button commandfor="confirm" command="--show">Delete…</helix-button>
41
- * <helix-dialog id="confirm">…</helix-dialog>
42
- * ```
43
- *
44
- * `--show`, `--close` and `--toggle`. Custom commands rather than the built-in
45
- * `show-modal`, because those are defined against native elements and do
46
- * nothing when pointed at a custom one.
47
- *
48
- * @since 0.1.0
49
- * @status stable
50
- * @category overlays
51
- * @aliases Modal, Lightbox, Sheet
52
- * @documentation https://helix.fusion.dev/components/dialog
53
- *
54
- * @slot - The dialog's content.
55
- * @slot footer - The actions.
56
- * @slot header - The title, and anything beside it.
57
- * @slot icon - A single mark above the title, for a dialog that warns.
58
- *
59
- * @event {Event} close - Relayed, because the platform's own does not escape.
60
- * @event {HelixBeforeCloseEvent} helix-before-close - Fired before it closes, and cancelable.
61
- * @event {HelixBeforeOpenEvent} helix-before-open - Fired before it opens, and cancelable.
62
- * @event {Event} toggle - Says the state moved, carrying `newState` and `oldState`.
63
- *
64
- * @csspart dialog - The native dialog element.
65
- */
66
10
  let HelixHeadlessDialog = class HelixHeadlessDialog extends MixinControllerOpen(HelixElement) {
67
11
  constructor() {
68
12
  super(...arguments);
69
- /**
70
- * Accessible name for the dialog.
71
- */
72
13
  this.label = '';
73
- /* Opened and closed from anywhere on the page, without a line of script:
74
- `<helix-button commandfor="…" command="--show">`. See `CommandController`
75
- for why this is the platform's invoker rather than a `data-` recipe. */
76
14
  this.commands = new CommandController(this, {
77
15
  commands: {
78
16
  '--close': () => this.close(),
@@ -80,28 +18,8 @@ let HelixHeadlessDialog = class HelixHeadlessDialog extends MixinControllerOpen(
80
18
  '--toggle': () => this.toggleOpen(),
81
19
  },
82
20
  });
83
- /**
84
- * Whether Escape or a press on the backdrop can close it.
85
- *
86
- * Off for a dialog that must be answered. It does not disable the close
87
- * control a consumer slots in: refusing to be dismissed is not the same as
88
- * refusing to be closed, and a dialog with no way out at all is a trap.
89
- *
90
- * A persistent dialog does not ignore that press. It answers it: see
91
- * `refuse`, which is the only way a dialog that will not be dismissed can say
92
- * so at the moment a reader asks.
93
- */
94
21
  this.persistent = false;
95
- /** What a value set before the element existed is kept in. */
96
22
  this.returned = '';
97
- /**
98
- * Escape, intercepted so it is a request rather than an act.
99
- *
100
- * The native `cancel` event fires before the dialog closes, so preventing it
101
- * leaves the dialog open and lets `requestOpen` decide. Without this a reader
102
- * pressing Escape would close a dialog its consumer had said was open, and
103
- * the two would disagree until something else moved.
104
- */
105
23
  this.handleCancel = (event) => {
106
24
  event.preventDefault();
107
25
  if (this.persistent) {
@@ -109,39 +27,12 @@ let HelixHeadlessDialog = class HelixHeadlessDialog extends MixinControllerOpen(
109
27
  }
110
28
  this.requestOpen(false);
111
29
  };
112
- /**
113
- * A close that happened anyway, reflected back.
114
- *
115
- * `<form method="dialog">` closes the dialog directly and there is no event
116
- * to prevent. Rather than fight it, the state follows: the dialog is shut, so
117
- * saying otherwise would be a lie the next `showModal()` would trip over.
118
- */
119
30
  this.handleClose = (event) => {
120
31
  if (this.open) {
121
32
  this.requestOpen(false);
122
33
  }
123
- /* And under the platform's own name, from the host. `close` on a `<dialog>`
124
- neither bubbles nor is composed, so the one the browser just fired reaches
125
- nobody outside this shadow root, which is the same reason `Avatar` relays
126
- `load`. Announced after the state has moved, so a consumer binding
127
- `onClose` reads a dialog that is already shut.
128
-
129
- `cancel` is deliberately not relayed. `handleCancel` prevents it so that
130
- Escape is a request rather than an act, so the native one never completes:
131
- relaying it would announce a dismissal that was refused. */
132
34
  this.relayNativeEvent(event);
133
35
  };
134
- /**
135
- * A press on the backdrop, which lands on the dialog element itself.
136
- *
137
- * **The target alone is not enough here, where it is for `Drawer`.** A modal
138
- * `<dialog>` attributes backdrop clicks to itself, which is the usual trick,
139
- * and this panel also carries `--dialog-padding` of padding: a press in that
140
- * padding has the same target as a press on the backdrop, so the trick alone
141
- * closes a dialog when a reader clicks just inside its own edge. The drawer
142
- * escapes that because its panel is flush against the window and its content
143
- * fills it. So the point is compared against the panel's box.
144
- */
145
36
  this.handleBackdrop = (event) => {
146
37
  const panel = this.dialog;
147
38
  if (!panel || event.target !== panel) {
@@ -161,18 +52,6 @@ let HelixHeadlessDialog = class HelixHeadlessDialog extends MixinControllerOpen(
161
52
  this.requestOpen(false);
162
53
  };
163
54
  }
164
- /**
165
- * What the dialog closed with, the way `<dialog>` reports it.
166
- *
167
- * **The whole point of a `<dialog>` returning a value is lost behind a shadow
168
- * boundary**, which is where this one is: the platform's contract is
169
- * `close(value)` then read `returnValue`, and `querySelector` does not reach
170
- * the element to do either. A confirmation asking which button was pressed is
171
- * the ordinary case.
172
- *
173
- * Held here as well as on the element, so a value set before the first render
174
- * survives to it and a consumer reading one back gets what they wrote.
175
- */
176
55
  get returnValue() {
177
56
  return this.dialog?.returnValue ?? this.returned;
178
57
  }
@@ -183,25 +62,12 @@ let HelixHeadlessDialog = class HelixHeadlessDialog extends MixinControllerOpen(
183
62
  dialog.returnValue = value;
184
63
  }
185
64
  }
186
- /** The native dialog, or nothing where there is no DOM to hold one. */
187
65
  get dialog() {
188
66
  return this.renderRoot?.querySelector('dialog') ?? null;
189
67
  }
190
- /** Moves focus into the dialog and makes the rest of the page inert. */
191
68
  show() {
192
69
  this.requestOpen(true);
193
70
  }
194
- /**
195
- * Asks to close, which a consumer may refuse.
196
- *
197
- * Takes what the dialog closed with, the way `<dialog>.close(value)` does, so
198
- * the button that dismissed it can say which one it was. Set before the
199
- * request rather than after, because a `close` listener reads `returnValue`
200
- * and would otherwise read the previous one.
201
- *
202
- * Not set where the request is refused, since nothing closed and a value
203
- * saying it did would outlive the refusal.
204
- */
205
71
  close(returnValue) {
206
72
  const previous = this.returnValue;
207
73
  if (returnValue !== undefined) {
@@ -217,78 +83,25 @@ let HelixHeadlessDialog = class HelixHeadlessDialog extends MixinControllerOpen(
217
83
  }
218
84
  updated(changed) {
219
85
  super.updated(changed);
220
- /* After render, because the element has to exist to be opened, and only on
221
- a difference: `showModal()` on an open dialog throws, and `close()` on a
222
- shut one fires a second `close` event. */
223
86
  const dialog = this.dialog;
224
87
  if (!dialog) {
225
88
  return;
226
89
  }
227
90
  if (this.open && !dialog.open) {
228
- /* **After the slotted elements have rendered, or the modal focuses
229
- itself.** `showModal()` moves focus to the first focusable descendant,
230
- and a custom element that has not rendered its shadow yet has none: the
231
- browser falls back to the dialog. Markup never showed this, the parser
232
- having upgraded everything before this element's first update, and
233
- React does: it creates and connects the dialog before the chat inside
234
- it has rendered, so a `default-open` dialog opened with focus on the
235
- dialog rather than on the field, measured against the markup twin.
236
-
237
- Awaited only for children that are Lit elements and only on the way
238
- open; a dialog with no such children resolves on the same microtask and
239
- is unchanged. */
240
91
  void this.openWhenSlotted(dialog);
241
92
  }
242
93
  else if (!this.open && dialog.open) {
243
94
  dialog.close();
244
95
  }
245
96
  }
246
- /** Opens once anything slotted has had its first render. See `updated`. */
247
97
  async openWhenSlotted(dialog) {
248
98
  await Promise.all([...this.children]
249
99
  .map((one) => one.updateComplete)
250
100
  .filter((one) => one instanceof Promise));
251
- /* Re-checked, because both facts can have moved while that resolved. */
252
101
  if (this.open && !dialog.open && this.isConnected) {
253
102
  dialog.showModal();
254
103
  }
255
104
  }
256
- /**
257
- * Says no to a dismissal, by moving.
258
- *
259
- * A persistent dialog that ignores a press on the backdrop is
260
- * indistinguishable from one that did not notice, and a reader's next move is
261
- * to press again harder. A short swell and settle is the smallest thing that
262
- * reads as "I heard you, and no": it is the same gesture a native alert makes
263
- * on macOS and the one every reader has already been taught.
264
- *
265
- * An attribute for the length of the animation rather than a class, cleared
266
- * on `animationend`, so the styled tier decides what refusing looks like and
267
- * this decides only when it happens. Removed first so a second press
268
- * restarts it: without that, pressing twice does nothing the second time,
269
- * which is the impression the whole thing exists to avoid.
270
- *
271
- * **`once: true` is not a release when the event cannot arrive.** Under
272
- * `prefers-reduced-motion: reduce` the styled tier sets `animation: none`, so
273
- * no animation runs, `animationend` never fires, and every refused press left
274
- * a listener that could not be removed and an attribute that stayed on. The
275
- * controller bounds it to one listener, aborted before the next is added.
276
- *
277
- * **And the attribute is cleared at once when no animation is running**, asked
278
- * of the platform rather than of a media query: this tier does not know what
279
- * refusing looks like, and the same silence arrives from a consumer's
280
- * stylesheet, from reduced motion, and from no styled tier being loaded at
281
- * all.
282
- *
283
- * **`CSSAnimation` specifically, because `getAnimations` returns transitions
284
- * too.** A panel that is still running its entrance transition answers the
285
- * bare `length` check with a truthy number while no animation exists to end,
286
- * so a refusal landing inside that window took the listener branch and waited
287
- * for an `animationend` that was never coming: the attribute stayed on until
288
- * the next refused press. That is the same bug this method exists to close,
289
- * one window narrower. `getAnimations` flushes pending style itself, so no
290
- * reflow is forced for it.
291
- */
292
105
  refuse() {
293
106
  const panel = this.dialog;
294
107
  if (!panel) {
@@ -296,8 +109,6 @@ let HelixHeadlessDialog = class HelixHeadlessDialog extends MixinControllerOpen(
296
109
  }
297
110
  this.refusal?.abort();
298
111
  panel.removeAttribute('data-refused');
299
- // Read forces the removal to land before the attribute goes back on, or the
300
- // two happen in one frame and the animation never restarts.
301
112
  void panel.offsetWidth;
302
113
  panel.setAttribute('data-refused', '');
303
114
  const animating = panel.getAnimations().some((one) => one instanceof CSSAnimation);
@@ -7,70 +7,15 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { customElement, HelixElement, slotFilled } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
- /**
11
- * @summary Separates content into groups with a rule.
12
- *
13
- * @description
14
- * The separator role lives on the rule inside the shadow root rather than on
15
- * the host. A role does not need to cross the shadow boundary the way an IDREF
16
- * does, and putting it on the rendered element means the semantics are in the
17
- * server's markup with no attribute written during a lifecycle the server never
18
- * runs.
19
- *
20
- * Use `orientation="vertical"` between items on one line, such as inside a
21
- * toolbar. A vertical divider stretches to its flex parent rather than setting
22
- * a height of its own.
23
- *
24
- * @since 0.1.0
25
- * @status stable
26
- * @category layout
27
- * @aliases Separator, Rule
28
- * @documentation https://helix.fusion.dev/components/divider
29
- *
30
- * @slot - Content set into the rule, such as the word between two sections.
31
- *
32
- * @csspart content - What is set into it.
33
- * @csspart line - The rule itself, or each half of it where something is set in.
34
- */
35
10
  let HelixHeadlessDivider = class HelixHeadlessDivider extends HelixElement {
36
11
  constructor() {
37
12
  super(...arguments);
38
- /**
39
- * Whether the rule is announced as a separator.
40
- *
41
- * Off by default, and out of the accessibility tree. Most rules on a page
42
- * separate nothing: they underline a heading or close a section that space
43
- * had already closed, and announcing every one as a separator is noise a
44
- * screen-reader user has to walk past.
45
- *
46
- * An opt-in, not a `decorative` opt-out, because a boolean attribute cannot
47
- * be turned off: `decorative="false"` parses as `true`, which would put the
48
- * quiet default out of reach from HTML and leave it settable only as a
49
- * property. An opt-in is absent by default, which every reflected property
50
- * here has to be.
51
- */
52
13
  this.announced = false;
53
14
  }
54
- /**
55
- * Marks the middle region when nothing has been set into it.
56
- *
57
- * A divider with no content has to be one continuous line, and the space
58
- * either side of the content is what would break it. `:empty` cannot answer
59
- * this and neither can `:host(:has(...))`, which is invalid CSS dropped at
60
- * parse time.
61
- *
62
- * On the region inside the shadow root rather than on the host, because an
63
- * attribute written onto the host is a change to markup the consumer rendered.
64
- */
65
15
  markEmpty() {
66
16
  const region = this.renderRoot?.querySelector('[part="content"]');
67
17
  const empty = !slotFilled(this.renderRoot?.querySelector('slot'));
68
18
  region?.toggleAttribute('data-empty', empty);
69
- /* `hidden` beside the mirror, because `data-empty` is a hook the styled
70
- sheet reads and this tier has no sheet: an empty region left in the box
71
- tree is a gap a consumer cannot close without knowing to look for it. The
72
- styled rule stays, because that sheet sets `display` on this part and so
73
- outranks the UA one. */
74
19
  region?.toggleAttribute('hidden', empty);
75
20
  }
76
21
  updated(changed) {
@@ -78,22 +23,6 @@ let HelixHeadlessDivider = class HelixHeadlessDivider extends HelixElement {
78
23
  this.markEmpty();
79
24
  }
80
25
  render() {
81
- /* Two rules and a slot between them, always.
82
- *
83
- * `role="presentation"` is the honest default; `announced` is for the rule
84
- * that genuinely divides one thing from another rather than just drawing a
85
- * line under it.
86
- *
87
- * **Both halves are always rendered and the semantics stay on the first.**
88
- * A rule with nothing set into it is one line with a zero-width slot and a
89
- * second line of no length, which costs a reader nothing and costs the
90
- * component the branch that would otherwise decide between two markups. It
91
- * also means a consumer who slots something in later gets it without the
92
- * element re-rendering into a different shape.
93
- *
94
- * The role is not repeated on the second half. Two separators where a reader
95
- * crossed one line is the announcement saying twice what happened once, and
96
- * the content between them is not a boundary of its own. */
97
26
  return html `<div
98
27
  part="line"
99
28
  role=${this.announced ? 'separator' : 'presentation'}
@@ -8,134 +8,27 @@ import { customElement, drawTooltip, HelixElement, MixinChartSource, seriesIndex
8
8
  import { html, nothing, svg } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
10
  import '../../tooltip/index.js';
11
- /** The drawing's own coordinates, square because the shape has to stay round. */
12
11
  const BOX = 100;
13
12
  const CENTER = BOX / 2;
14
- /** The outer edge of the ring, whatever its thickness. */
15
13
  const EDGE = 40;
16
- /**
17
- * The dash gap, long enough that the pattern cannot repeat.
18
- *
19
- * A dash array tiles. Set to `circumference - length` the pattern fills the
20
- * circle exactly once and is right at rest, and wrong the moment it animates:
21
- * both numbers interpolate, and while the gap is shorter than the path still to
22
- * cover, a second and third copy of the slice sweep round the ring and vanish
23
- * inside one transition. A constant far larger than any circumference has no
24
- * second tile to draw at any point in the interpolation. It is the number
25
- * `@starting-style` already uses for the same reason.
26
- */
27
14
  const GAP = 9999;
28
- /**
29
- * How much of the radius the ring takes, by name.
30
- *
31
- * **The outer edge is fixed and the radius follows the thickness**, which is
32
- * what makes `none` a pie. The stroke sits astride its own radius, so a ring
33
- * drawn at a fixed `r` grows outward as well as inward: against a 100 box, a
34
- * fixed `r=40` has an unstated ceiling at a thickness of 20, and anything past
35
- * it crosses the view box and is cut with nothing to say so.
36
- *
37
- * Derived instead, `r = EDGE - thickness / 2`, the outer edge lands on `EDGE`
38
- * for every value and the inner edge is `EDGE - thickness`. A pie is where the
39
- * inner edge reaches the center, which solves to `thickness = EDGE`: the ring
40
- * is then drawn at `r = EDGE / 2` and its stroke spans the whole radius.
41
- *
42
- * `EDGE * 2` was the first answer and is the trap in this arithmetic. It gives
43
- * `r = 0`, and a circle of radius zero draws nothing at all, so a pie came out
44
- * as an empty box. The stroke reaches `thickness / 2` either side of `r`, not
45
- * `thickness`.
46
- */
47
15
  const THICKNESS = {
48
16
  large: 8,
49
17
  medium: 16,
50
18
  none: EDGE,
51
19
  small: 26,
52
20
  };
53
- /**
54
- * @summary Parts of a whole as a ring, read from a table.
55
- *
56
- * @description
57
- * The chart with the narrowest honest use, so the component is narrow too.
58
- *
59
- * **It reads one column and treats the rows as the parts.** Every other chart
60
- * here takes a column per series; a ring has one whole to divide, and a second
61
- * ring inside the first is two charts sharing a picture in the most literal
62
- * way. So the first column of numbers is the series and each row is a slice.
63
- *
64
- * **It refuses negative values rather than drawing them.** A share of a whole
65
- * cannot be less than nothing, and an arc for a negative number either runs
66
- * backwards over its neighbors or is silently dropped. Those rows are skipped
67
- * and the total is of what is left, which is the only reading that stays true.
68
- *
69
- * **The shape is drawn with a dashed stroke, not with arc commands.** One
70
- * `<circle>` per slice, its dash set to the slice's share of the circumference
71
- * and its offset to where the slice begins. Arc paths need the sweep flag and a
72
- * large-arc flag, both of which flip at values a reader never sees coming, and
73
- * a slice of exactly one whole becomes a path with no length at all.
74
- *
75
- * **And it keeps its aspect ratio**, alone among the charts here. The others
76
- * set `preserveAspectRatio="none"` so one drawing fills any box and rescue
77
- * their strokes with `vector-effect`; that rescues strokes and not geometry, so
78
- * a ring drawn the same way is an ellipse in anything but a square.
79
- *
80
- * The table is the accessible representation and this drawing is `aria-hidden`.
81
- *
82
- * The properties below are written by this component and read by whoever draws
83
- * it. A consumer sets none of them: they carry a number this element computed
84
- * and CSS cannot, which is what lets a stylesheet on this tier draw the thing
85
- * without measuring anything itself.
86
- *
87
- * @since 0.1.0
88
- * @status experimental
89
- * @category charts
90
- * @aliases Pie Chart, Ring Chart, Share Chart
91
- * @documentation https://helix.fusion.dev/components/donut-chart
92
- *
93
- * @slot - Goes in the middle of the ring. A total, usually.
94
- *
95
- * @csspart center - The middle of the ring, holding whatever is slotted.
96
- * @csspart mark - The invisible point the tooltip is anchored to.
97
- * @csspart plot - The drawing.
98
- * @csspart segment - One slice.
99
- * @csspart segment-edge - One slice as a closed shape, for a boundary to trace.
100
- * @csspart tooltip - The bubble, forwarded from `Tooltip`.
101
- * @csspart tooltip-anchor - The `Tooltip` element itself.
102
- * @csspart tooltip-cell - One column's name and value inside the tooltip.
103
- * @csspart tooltip-label - The slice's own name inside the tooltip.
104
- * @csspart tooltip-name - A column's name inside the tooltip.
105
- * @csspart tooltip-value - A column's value inside the tooltip.
106
- * @csspart track - The ring the slices are drawn on.
107
- *
108
- * @cssproperty --donut-chart-stroke - How thick the ring is drawn, derived from `hole`.
109
- */
110
21
  let HelixHeadlessDonutChart = class HelixHeadlessDonutChart extends MixinChartSource(HelixElement) {
111
- /**
112
- * The pointer is not mapped across this one.
113
- *
114
- * Every other chart lays its readings out left to right, so the nearest is
115
- * arithmetic on x. A ring's slices are real elements at every angle, and x
116
- * alone would answer with whichever slice shared a horizontal position with
117
- * the pointer: at three o'clock and at nine o'clock it would give the same
118
- * reading for opposite sides of the chart. So the slices say which one they
119
- * are and this only holds the answer.
120
- *
121
- * Set here rather than in a constructor, and it must not move there: the
122
- * analyzer reads `this.pointer.axis = 'none'` in a constructor body as the
123
- * declaration of a public field, and fails the build demanding a description
124
- * for a member this class does not have.
125
- */
126
22
  connectedCallback() {
127
23
  super.connectedCallback();
128
24
  this.pointer.axis = 'none';
129
25
  }
130
- /** The fraction of the circle the shares are spread over. */
131
26
  get span() {
132
27
  return this.sweep === 'half' ? 0.5 : 1;
133
28
  }
134
- /** How thick the ring is drawn, in the drawing's own units. */
135
29
  get thickness() {
136
30
  return THICKNESS[this.hole ?? 'medium'] ?? THICKNESS.medium ?? 0;
137
31
  }
138
- /** The ring's radius, which follows its thickness so the outer edge holds. */
139
32
  get radius() {
140
33
  return EDGE - this.thickness / 2;
141
34
  }
@@ -158,24 +51,10 @@ let HelixHeadlessDonutChart = class HelixHeadlessDonutChart extends MixinChartSo
158
51
  }
159
52
  render() {
160
53
  const shares = this.shares;
161
- /* No middle where there is no hole. A `center` part over a pie would put
162
- slotted content on top of the slices, and a slot nobody can see is worse
163
- than one that is not there: a consumer fills it and nothing happens. */
164
54
  return html `${this.renderPlot(shares)}
165
55
  `;
166
56
  }
167
- /**
168
- * The ring itself, behind a seam the styled tier can move.
169
- *
170
- * `aria-hidden`, because the table it was read from is the readable version
171
- * and a screen reader hearing both hears the same numbers twice.
172
- */
173
57
  renderPlot(shares) {
174
- /* The thickness is declared here, on the box that holds both the ring and
175
- the middle, and never on the slices. A custom property inherits down and
176
- never sideways, so `[part="center"]` is a sibling of the circles and would
177
- only ever read the fallback, sizing the hole as if every ring were the
178
- resting thickness. */
179
58
  return html `<div
180
59
  aria-hidden="true"
181
60
  id="helix-chart-plot"
@@ -198,30 +77,15 @@ let HelixHeadlessDonutChart = class HelixHeadlessDonutChart extends MixinChartSo
198
77
  ${this.renderTooltip()}
199
78
  </div>`;
200
79
  }
201
- /**
202
- * The view box, cropped to what is actually drawn.
203
- *
204
- * A full turn uses the square. A half arc uses the band from the top of the
205
- * ring down to the center line, plus the half stroke its flat ends hang
206
- * below that, and cropping to it is the difference between a semi-donut and a
207
- * semi-donut with an empty quarter of the box under it. The outer edge is
208
- * `EDGE` from the center whatever the thickness, which is what makes this two
209
- * numbers rather than a measurement.
210
- */
211
80
  get frame() {
212
81
  if (this.sweep !== 'half') {
213
82
  return `0 0 ${BOX} ${BOX}`;
214
83
  }
215
84
  return `0 ${CENTER - EDGE} ${BOX} ${EDGE + this.thickness / 2}`;
216
85
  }
217
- /** One slice, as a dash the length of its share. */
218
86
  renderSegment(share, index) {
219
87
  const circumference = 2 * Math.PI * this.radius;
220
88
  const length = share.fraction * circumference * this.span;
221
- /* Negative, because `stroke-dashoffset` counts backwards along the path:
222
- a positive offset would run the slices anticlockwise from twelve. The
223
- rotation that puts zero at the top is the stylesheet's, since it is a
224
- fact about how the picture is presented rather than about the numbers. */
225
89
  return svg `<circle
226
90
  cx=${CENTER}
227
91
  cy=${CENTER}
@@ -237,36 +101,6 @@ let HelixHeadlessDonutChart = class HelixHeadlessDonutChart extends MixinChartSo
237
101
  />
238
102
  ${this.renderEdge(share, index)}`;
239
103
  }
240
- /**
241
- * The same slice again, as a shape with an inside.
242
- *
243
- * **A stroked arc has no boundary to draw, which is why this exists.** The
244
- * segment above is a dash on a circle: the mark *is* the edge, so there is
245
- * nothing for an outline to trace and nothing within it to fade. Anything
246
- * wanting those two separately needs the slice as a closed path, and this is
247
- * it: the outer arc, a step inward, the inner arc back, closed.
248
- *
249
- * **Rendered always and painted only sometimes**, which is what keeps this on
250
- * this tier. Whether a boundary is drawn is a styled question and the answer
251
- * lives in a stylesheet; whether the geometry a boundary would need exists at
252
- * all is structural, and a component cannot conjure a shape from CSS. So the
253
- * path is here for every donut and is `fill: none; stroke: none` until
254
- * something asks for it.
255
- *
256
- * **Not a replacement for the dash.** The dash carries three jobs and only
257
- * one of them is the shape: a share growing is a `stroke-dasharray`
258
- * interpolating with no reflow, and `@starting-style` on that same property is
259
- * the whole reason the ring sweeps in rather than appearing. A `d` cannot do
260
- * either. CSS interpolates one only between paths of identical command
261
- * structure, and the large-arc flag flips the moment a slice crosses a half
262
- * turn, so a growing slice would pop partway through the transition it was
263
- * meant to smooth. Both animations stay on the dash and this path follows it.
264
- *
265
- * The turn to twelve o'clock is arithmetic here rather than the rotation the
266
- * stylesheet gives the circles. That rule uses `transform-box: fill-box`, so
267
- * it spins each element about its own bounding box, which is the ring's center
268
- * for a circle and is not for a sector.
269
- */
270
104
  renderEdge(share, index) {
271
105
  const turn = 2 * Math.PI * this.span;
272
106
  const from = share.start * turn - Math.PI / 2;
@@ -275,45 +109,17 @@ let HelixHeadlessDonutChart = class HelixHeadlessDonutChart extends MixinChartSo
275
109
  const inner = EDGE - this.thickness;
276
110
  const at = (radius, angle) => `${(CENTER + Math.cos(angle) * radius).toFixed(3)} ${(CENTER + Math.sin(angle) * radius).toFixed(3)}`;
277
111
  const wide = to - from > Math.PI ? 1 : 0;
278
- /* A slice that is the whole ring starts and ends at one point, and an arc
279
- between a point and itself draws nothing. Split at the halfway angle so
280
- each half is a real arc. */
281
112
  const full = to - from >= 2 * Math.PI - 1e-6;
282
113
  const mid = from + (to - from) / 2;
283
114
  const outward = full
284
115
  ? `A ${outer} ${outer} 0 0 1 ${at(outer, mid)} A ${outer} ${outer} 0 0 1 ${at(outer, to)}`
285
116
  : `A ${outer} ${outer} 0 ${wide} 1 ${at(outer, to)}`;
286
- /* A pie has no inner arc: the radius solves to zero and the two ends meet at
287
- the middle, so the step back is a line to the center and not an arc of no
288
- size. */
289
117
  const homeward = inner <= 0
290
118
  ? `L ${CENTER} ${CENTER}`
291
119
  : full
292
120
  ? `L ${at(inner, to)} A ${inner} ${inner} 0 0 0 ${at(inner, mid)} A ${inner} ${inner} 0 0 0 ${at(inner, from)}`
293
121
  : `L ${at(inner, to)} A ${inner} ${inner} 0 ${wide} 0 ${at(inner, from)}`;
294
122
  const shape = `M ${at(outer, from)} ${outward} ${homeward} Z`;
295
- /* **Clipped to itself, because a stroke straddles the line it is drawn on.**
296
- Half of it falls outside the wedge: past the fill on both arcs, and across
297
- the seam into the neighbor on both radial sides, where that neighbor is
298
- drawing the same line from the other direction and the two overlap. Seen
299
- as a doubled edge at every join and a halo around the ring.
300
-
301
- Clipping to the same path keeps the inner half and drops the outer, so an
302
- edge stays inside the slice it belongs to and two adjacent edges meet
303
- exactly. The clip is the shape itself, so it costs no second geometry and
304
- cannot drift from it.
305
-
306
- Ids are scoped to the shadow root, so an index is enough to keep them
307
- apart, and two donuts on a page cannot collide. */
308
- /* **`fill="none"` as an attribute, not a rule, because this tier has
309
- no rules.** An SVG path fills black by default, and the styled tier's
310
- own `fill: none` covers only that tier: everywhere else these closed
311
- shapes paint over the ring.
312
-
313
- A presentation attribute loses to any CSS rule, so the styled tier
314
- still decides and a consumer can still fill this if they want one.
315
- What it cannot do is fill by accident on the tier whose whole premise
316
- is that it ships no CSS. */
317
123
  const clip = `helix-donut-edge-${index}`;
318
124
  return svg `<clipPath id=${clip}><path d=${shape} /></clipPath>
319
125
  <path
@@ -326,37 +132,17 @@ let HelixHeadlessDonutChart = class HelixHeadlessDonutChart extends MixinChartSo
326
132
  style="--chart-series: var(--chart-series-${seriesIndex(index)})"
327
133
  />`;
328
134
  }
329
- /**
330
- * What a pointer resting on a slice shows.
331
- *
332
- * Placed in the middle rather than along an axis, a ring having none, and the
333
- * middle is where a reader is already looking: the hole usually holds a
334
- * total, so a tooltip over it replaces one figure with the one being asked
335
- * about rather than covering something else.
336
- */
337
135
  renderTooltip() {
338
136
  const at = this.pointer.at;
339
137
  const share = this.shares[at];
340
138
  if (!share) {
341
139
  return drawTooltip(this.sourceRows, at, this.tag('tooltip'));
342
140
  }
343
- /* **Outside the ring, on the slice's own bearing**, rather than centered in
344
- the hole: centered covers the data on a pie, and in neither shape does it
345
- say *which* slice it means. Out here one rule serves both.
346
-
347
- The mid-angle, less a quarter turn because the ring is rotated to start
348
- at twelve o'clock and this arithmetic starts at three. The point is the
349
- outer edge, and the box is pushed a little further along the same bearing
350
- so its arrow rests on the ring rather than in it. */
351
141
  const angle = (share.start + share.fraction / 2) * 2 * Math.PI - Math.PI / 2;
352
142
  const edge = EDGE;
353
143
  const x = CENTER + Math.cos(angle) * edge;
354
144
  const y = CENTER + Math.sin(angle) * edge;
355
145
  return drawTooltip(this.sourceRows, at, this.tag('tooltip'), {
356
- /* The preferred side from the bearing's dominant axis, and
357
- `FloatingController` moves it if that side does not fit. A fixed side
358
- cannot express a bearing, so the arrow misses the ring; a preference
359
- plus flipping can. */
360
146
  placement: Math.abs(Math.cos(angle)) > Math.abs(Math.sin(angle))
361
147
  ? Math.cos(angle) > 0
362
148
  ? 'right'