@rogieking/figui3 9.3.0 → 9.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.
package/fig.js CHANGED
@@ -4798,6 +4798,513 @@ class FigTabs extends HTMLElement {
4798
4798
  }
4799
4799
  figDefineElement("fig-tabs", FigTabs);
4800
4800
 
4801
+ /* Toolbelt */
4802
+ /**
4803
+ * A semantic item group for use within FigToolbelt.
4804
+ */
4805
+ class FigToolbeltGroup extends HTMLElement {
4806
+ connectedCallback() {
4807
+ if (!this.hasAttribute("role")) this.setAttribute("role", "group");
4808
+ }
4809
+ }
4810
+ figDefineElement("fig-toolbelt-group", FigToolbeltGroup);
4811
+
4812
+ /**
4813
+ * A selectable item for use within FigToolbelt.
4814
+ * @attr {string} value - The value associated with the item
4815
+ * @attr {boolean} selected - Whether the item is currently selected
4816
+ * @attr {boolean} disabled - Whether the item is unavailable
4817
+ */
4818
+ class FigToolbeltItem extends HTMLElement {
4819
+ static get observedAttributes() {
4820
+ return ["selected", "disabled"];
4821
+ }
4822
+
4823
+ connectedCallback() {
4824
+ if (!this.hasAttribute("role")) this.setAttribute("role", "button");
4825
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
4826
+ this.#syncA11yState();
4827
+ }
4828
+
4829
+ get value() {
4830
+ const value = this.getAttribute("value");
4831
+ return value !== null ? value : this.textContent?.trim() || "";
4832
+ }
4833
+
4834
+ set value(value) {
4835
+ this.setAttribute("value", value ?? "");
4836
+ }
4837
+
4838
+ get selected() {
4839
+ return figBooleanAttribute(this, "selected");
4840
+ }
4841
+
4842
+ set selected(value) {
4843
+ this.toggleAttribute("selected", Boolean(value));
4844
+ }
4845
+
4846
+ get disabled() {
4847
+ return figBooleanAttribute(this, "disabled");
4848
+ }
4849
+
4850
+ set disabled(value) {
4851
+ this.toggleAttribute("disabled", Boolean(value));
4852
+ }
4853
+
4854
+ attributeChangedCallback() {
4855
+ this.#syncA11yState();
4856
+ }
4857
+
4858
+ #syncA11yState() {
4859
+ const selected = figBooleanAttribute(this, "selected");
4860
+ const disabled = figBooleanAttribute(this, "disabled");
4861
+ this.setAttribute("aria-pressed", selected ? "true" : "false");
4862
+ if (disabled) {
4863
+ this.setAttribute("aria-disabled", "true");
4864
+ this.setAttribute("tabindex", "-1");
4865
+ } else {
4866
+ this.removeAttribute("aria-disabled");
4867
+ }
4868
+ }
4869
+ }
4870
+ figDefineElement("fig-toolbelt-item", FigToolbeltItem);
4871
+
4872
+ /**
4873
+ * A selectable toolbar with configurable layout and overflow controls.
4874
+ * @attr {string} value - The selected item value
4875
+ * @attr {boolean} disabled - Disables all items
4876
+ * @attr {"horizontal"|"vertical"} layout - Item direction (default: horizontal)
4877
+ * @attr {"buttons"|"scrollbar"} overflow - Overflow controls (default: scrollbar)
4878
+ * @fires input - Selected value
4879
+ * @fires change - Selected value
4880
+ */
4881
+ class FigToolbelt extends HTMLElement {
4882
+ #boundHandleClick = this.#handleClick.bind(this);
4883
+ #boundHandleKeyDown = this.#handleKeyDown.bind(this);
4884
+ #boundSyncOverflow = this.#syncOverflow.bind(this);
4885
+ #viewport = null;
4886
+ #mutationObserver = null;
4887
+ #resizeObserver = null;
4888
+ #navStart = null;
4889
+ #navEnd = null;
4890
+ #parentDisabledItems = new WeakSet();
4891
+ #syncingValue = false;
4892
+
4893
+ constructor() {
4894
+ super();
4895
+ const shadow = this.attachShadow({ mode: "open" });
4896
+ const frame = document.createElement("div");
4897
+ frame.setAttribute("part", "frame");
4898
+
4899
+ const startSlot = document.createElement("slot");
4900
+ startSlot.name = "start";
4901
+ startSlot.style.display = "contents";
4902
+
4903
+ this.#viewport = document.createElement("div");
4904
+ this.#viewport.className = "fig-overflow-fade";
4905
+ this.#viewport.setAttribute("part", "viewport");
4906
+ const itemSlot = document.createElement("slot");
4907
+ itemSlot.style.display = "contents";
4908
+ this.#viewport.append(itemSlot);
4909
+
4910
+ const endSlot = document.createElement("slot");
4911
+ endSlot.name = "end";
4912
+ endSlot.style.display = "contents";
4913
+
4914
+ frame.append(startSlot, this.#viewport, endSlot);
4915
+ shadow.append(frame);
4916
+ }
4917
+
4918
+ static get observedAttributes() {
4919
+ return ["value", "disabled", "layout", "overflow"];
4920
+ }
4921
+
4922
+ get #isVertical() {
4923
+ return this.getAttribute("layout") === "vertical";
4924
+ }
4925
+
4926
+ get #overflowMode() {
4927
+ return this.getAttribute("overflow") === "buttons"
4928
+ ? "buttons"
4929
+ : "scrollbar";
4930
+ }
4931
+
4932
+ connectedCallback() {
4933
+ if (!this.hasAttribute("role")) this.setAttribute("role", "toolbar");
4934
+ this.#syncOrientation();
4935
+ this.addEventListener("click", this.#boundHandleClick);
4936
+ this.addEventListener("keydown", this.#boundHandleKeyDown);
4937
+ this.#viewport.addEventListener("scroll", this.#boundSyncOverflow);
4938
+ this.#applyOverflowMode();
4939
+ this.#startObserver();
4940
+ this.#startResizeObserver();
4941
+ figNextFrame(this, () => {
4942
+ this.#syncSelection();
4943
+ this.#applyDisabled(figBooleanAttribute(this, "disabled"));
4944
+ this.#syncTabIndexes();
4945
+ this.#syncOverflow();
4946
+ this.#scrollSelectedItemIntoView(undefined, "auto");
4947
+ });
4948
+ }
4949
+
4950
+ disconnectedCallback() {
4951
+ this.removeEventListener("click", this.#boundHandleClick);
4952
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
4953
+ this.#viewport.removeEventListener("scroll", this.#boundSyncOverflow);
4954
+ this.#mutationObserver?.disconnect();
4955
+ this.#mutationObserver = null;
4956
+ this.#resizeObserver?.disconnect();
4957
+ this.#resizeObserver = null;
4958
+ this.#removeNavButtons();
4959
+ }
4960
+
4961
+ get value() {
4962
+ return this.selectedItem?.value || "";
4963
+ }
4964
+
4965
+ set value(value) {
4966
+ if (value === null || value === undefined) {
4967
+ this.removeAttribute("value");
4968
+ } else {
4969
+ this.setAttribute("value", String(value));
4970
+ }
4971
+ }
4972
+
4973
+ get selectedItem() {
4974
+ return this.#items().find((item) => figBooleanAttribute(item, "selected")) || null;
4975
+ }
4976
+
4977
+ attributeChangedCallback(name, oldValue, newValue) {
4978
+ if (oldValue === newValue) return;
4979
+ if (name === "value" && !this.#syncingValue) {
4980
+ this.#selectByValue(newValue);
4981
+ }
4982
+ if (name === "disabled") {
4983
+ this.#applyDisabled(newValue !== null && newValue !== "false");
4984
+ }
4985
+ if (name === "overflow") {
4986
+ this.#applyOverflowMode();
4987
+ requestAnimationFrame(() => this.#syncOverflow());
4988
+ }
4989
+ if (name === "layout") {
4990
+ this.#syncOrientation();
4991
+ if (this.#isVertical) this.#viewport.scrollLeft = 0;
4992
+ else this.#viewport.scrollTop = 0;
4993
+ this.#applyOverflowMode();
4994
+ requestAnimationFrame(() => {
4995
+ this.#syncOverflow();
4996
+ this.#scrollSelectedItemIntoView(undefined, "auto");
4997
+ });
4998
+ }
4999
+ }
5000
+
5001
+ #items() {
5002
+ return Array.from(this.querySelectorAll("fig-toolbelt-item")).filter(
5003
+ (item) => item.closest("fig-toolbelt") === this,
5004
+ );
5005
+ }
5006
+
5007
+ #availableItems() {
5008
+ return this.#items().filter((item) => !figBooleanAttribute(item, "disabled"));
5009
+ }
5010
+
5011
+ #applyDisabled(disabled) {
5012
+ if (disabled) this.setAttribute("aria-disabled", "true");
5013
+ else this.removeAttribute("aria-disabled");
5014
+ for (const item of this.#items()) {
5015
+ if (disabled) {
5016
+ if (!figBooleanAttribute(item, "disabled")) {
5017
+ this.#parentDisabledItems.add(item);
5018
+ item.setAttribute("disabled", "");
5019
+ }
5020
+ } else if (this.#parentDisabledItems.has(item)) {
5021
+ this.#parentDisabledItems.delete(item);
5022
+ item.removeAttribute("disabled");
5023
+ }
5024
+ }
5025
+ this.#syncTabIndexes();
5026
+ }
5027
+
5028
+ #syncSelection() {
5029
+ const declaredValue = this.getAttribute("value");
5030
+ if (declaredValue !== null) {
5031
+ this.#selectByValue(declaredValue);
5032
+ return;
5033
+ }
5034
+
5035
+ const selectedItems = this.#items().filter((item) =>
5036
+ figBooleanAttribute(item, "selected"),
5037
+ );
5038
+ const selected = selectedItems[0] || null;
5039
+ for (const item of selectedItems.slice(1)) {
5040
+ item.removeAttribute("selected");
5041
+ }
5042
+ if (selected) this.#reflectValue(selected.value);
5043
+ }
5044
+
5045
+ #selectByValue(value) {
5046
+ const target =
5047
+ value === null
5048
+ ? null
5049
+ : this.#items().find((item) => item.value === value) || null;
5050
+ for (const item of this.#items()) {
5051
+ item.toggleAttribute("selected", item === target);
5052
+ }
5053
+ this.#syncTabIndexes(target);
5054
+ if (target) this.#scrollSelectedItemIntoView(target);
5055
+ }
5056
+
5057
+ #selectItem(target, { emit = false, focus = false } = {}) {
5058
+ if (!target || figBooleanAttribute(target, "disabled")) return false;
5059
+ const previousItem = this.selectedItem;
5060
+ const previousValue = this.value;
5061
+ for (const item of this.#items()) {
5062
+ item.toggleAttribute("selected", item === target);
5063
+ }
5064
+ this.#reflectValue(target.value);
5065
+ this.#syncTabIndexes(target);
5066
+ if (focus) target.focus();
5067
+ this.#scrollSelectedItemIntoView(target);
5068
+ if (emit && (previousItem !== target || previousValue !== target.value)) {
5069
+ this.#emitSelectionEvents();
5070
+ }
5071
+ return true;
5072
+ }
5073
+
5074
+ #reflectValue(value) {
5075
+ this.#syncingValue = true;
5076
+ this.setAttribute("value", value);
5077
+ this.#syncingValue = false;
5078
+ }
5079
+
5080
+ #syncTabIndexes(preferredItem) {
5081
+ const items = this.#items();
5082
+ const availableItems = this.#availableItems();
5083
+ const active =
5084
+ (preferredItem && availableItems.includes(preferredItem) && preferredItem) ||
5085
+ (availableItems.includes(document.activeElement) && document.activeElement) ||
5086
+ (availableItems.includes(this.selectedItem) && this.selectedItem) ||
5087
+ availableItems[0] ||
5088
+ null;
5089
+ for (const item of items) {
5090
+ item.setAttribute("tabindex", item === active ? "0" : "-1");
5091
+ }
5092
+ }
5093
+
5094
+ #handleClick(event) {
5095
+ if (figBooleanAttribute(this, "disabled")) return;
5096
+ const target = event.target.closest?.("fig-toolbelt-item");
5097
+ if (!target || target.closest("fig-toolbelt") !== this) return;
5098
+ this.#selectItem(target, { emit: true });
5099
+ }
5100
+
5101
+ #handleKeyDown(event) {
5102
+ if (figBooleanAttribute(this, "disabled")) return;
5103
+ const target = event.target.closest?.("fig-toolbelt-item");
5104
+ if (!target || target.closest("fig-toolbelt") !== this) return;
5105
+
5106
+ if (event.key === "Enter" || event.key === " ") {
5107
+ event.preventDefault();
5108
+ this.#selectItem(target, { emit: true });
5109
+ return;
5110
+ }
5111
+
5112
+ const items = this.#availableItems();
5113
+ if (!items.length) return;
5114
+ const currentIndex = Math.max(0, items.indexOf(target));
5115
+ let nextIndex = currentIndex;
5116
+ switch (event.key) {
5117
+ case "ArrowLeft":
5118
+ if (this.#isVertical) return;
5119
+ nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
5120
+ break;
5121
+ case "ArrowUp":
5122
+ if (!this.#isVertical) return;
5123
+ nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
5124
+ break;
5125
+ case "ArrowRight":
5126
+ if (this.#isVertical) return;
5127
+ nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
5128
+ break;
5129
+ case "ArrowDown":
5130
+ if (!this.#isVertical) return;
5131
+ nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
5132
+ break;
5133
+ case "Home":
5134
+ nextIndex = 0;
5135
+ break;
5136
+ case "End":
5137
+ nextIndex = items.length - 1;
5138
+ break;
5139
+ default:
5140
+ return;
5141
+ }
5142
+ event.preventDefault();
5143
+ this.#syncTabIndexes(items[nextIndex]);
5144
+ items[nextIndex].focus();
5145
+ this.#scrollItemIntoView(items[nextIndex]);
5146
+ }
5147
+
5148
+ #emitSelectionEvents() {
5149
+ const detail = this.value;
5150
+ this.dispatchEvent(new CustomEvent("input", { detail, bubbles: true }));
5151
+ this.dispatchEvent(new CustomEvent("change", { detail, bubbles: true }));
5152
+ }
5153
+
5154
+ #startObserver() {
5155
+ this.#mutationObserver?.disconnect();
5156
+ this.#mutationObserver = new MutationObserver((records) => {
5157
+ const authoredChange = records.some(
5158
+ (record) =>
5159
+ record.type === "childList" ||
5160
+ (record.target instanceof Element &&
5161
+ record.target.matches("fig-toolbelt-item") &&
5162
+ record.target.closest("fig-toolbelt") === this),
5163
+ );
5164
+ if (!authoredChange) return;
5165
+ this.#applyOverflowMode();
5166
+ this.#syncSelection();
5167
+ this.#applyDisabled(figBooleanAttribute(this, "disabled"));
5168
+ this.#syncTabIndexes();
5169
+ requestAnimationFrame(() => {
5170
+ this.#syncOverflow();
5171
+ this.#scrollSelectedItemIntoView();
5172
+ });
5173
+ });
5174
+ this.#mutationObserver.observe(this, {
5175
+ childList: true,
5176
+ attributes: true,
5177
+ subtree: true,
5178
+ attributeFilter: ["value", "selected", "disabled"],
5179
+ });
5180
+ }
5181
+
5182
+ #startResizeObserver() {
5183
+ this.#resizeObserver?.disconnect();
5184
+ this.#resizeObserver = new ResizeObserver(() => this.#syncOverflow());
5185
+ this.#resizeObserver.observe(this);
5186
+ this.#resizeObserver.observe(this.#viewport);
5187
+ }
5188
+
5189
+ #syncOverflow() {
5190
+ if (this.#overflowMode === "scrollbar") return;
5191
+ figSyncOverflowState(
5192
+ this,
5193
+ this.#viewport,
5194
+ this.#isVertical ? "y" : "x",
5195
+ );
5196
+ }
5197
+
5198
+ #syncOrientation() {
5199
+ this.setAttribute(
5200
+ "aria-orientation",
5201
+ this.#isVertical ? "vertical" : "horizontal",
5202
+ );
5203
+ this.#viewport.classList.add("fig-overflow-fade");
5204
+ this.#viewport.classList.toggle(
5205
+ "fig-overflow-fade-horizontal",
5206
+ !this.#isVertical,
5207
+ );
5208
+ }
5209
+
5210
+ #applyOverflowMode() {
5211
+ if (this.#overflowMode === "scrollbar") {
5212
+ this.#removeNavButtons();
5213
+ } else {
5214
+ this.#createNavButtons();
5215
+ }
5216
+ }
5217
+
5218
+ #createNavButtons() {
5219
+ if (
5220
+ this.#navStart &&
5221
+ this.#navEnd &&
5222
+ this.contains(this.#navStart) &&
5223
+ this.contains(this.#navEnd)
5224
+ ) {
5225
+ this.#positionNavButtons();
5226
+ return;
5227
+ }
5228
+ this.#navStart?.remove();
5229
+ this.#navEnd?.remove();
5230
+ const buttons = createFigOverflowButtons({
5231
+ owner: "toolbelt",
5232
+ onStart: () => this.#scrollByPage(-1),
5233
+ onEnd: () => this.#scrollByPage(1),
5234
+ });
5235
+ this.#navStart = buttons.start;
5236
+ this.#navEnd = buttons.end;
5237
+ this.#navStart.setAttribute("slot", "start");
5238
+ this.#navEnd.setAttribute("slot", "end");
5239
+ this.#positionNavButtons();
5240
+ }
5241
+
5242
+ #positionNavButtons() {
5243
+ if (this.firstElementChild !== this.#navStart) this.prepend(this.#navStart);
5244
+ if (this.lastElementChild !== this.#navEnd) this.append(this.#navEnd);
5245
+ }
5246
+
5247
+ #removeNavButtons() {
5248
+ this.#navStart?.remove();
5249
+ this.#navEnd?.remove();
5250
+ this.#navStart = null;
5251
+ this.#navEnd = null;
5252
+ this.classList.remove("overflow-start", "overflow-end");
5253
+ }
5254
+
5255
+ #scrollByPage(direction) {
5256
+ figScrollOverflowPage(
5257
+ this.#viewport,
5258
+ this.#isVertical ? "y" : "x",
5259
+ direction,
5260
+ );
5261
+ }
5262
+
5263
+ #scrollSelectedItemIntoView(
5264
+ item = this.selectedItem,
5265
+ behavior = "smooth",
5266
+ ) {
5267
+ if (item) this.#scrollItemIntoView(item, behavior);
5268
+ }
5269
+
5270
+ #scrollItemIntoView(item, behavior = "smooth") {
5271
+ if (!item) return;
5272
+ requestAnimationFrame(() => {
5273
+ if (!this.isConnected || !item.isConnected) return;
5274
+ const isVertical = this.#isVertical;
5275
+ const hasOverflow = isVertical
5276
+ ? this.#viewport.scrollHeight > this.#viewport.clientHeight
5277
+ : this.#viewport.scrollWidth > this.#viewport.clientWidth;
5278
+ if (!hasOverflow) return;
5279
+ const containerRect = this.#viewport.getBoundingClientRect();
5280
+ const itemRect = item.getBoundingClientRect();
5281
+ if (isVertical) {
5282
+ const itemCenter =
5283
+ itemRect.top -
5284
+ containerRect.top +
5285
+ this.#viewport.scrollTop +
5286
+ itemRect.height / 2;
5287
+ this.#viewport.scrollTo({
5288
+ top: itemCenter - this.#viewport.clientHeight / 2,
5289
+ behavior,
5290
+ });
5291
+ } else {
5292
+ const itemCenter =
5293
+ itemRect.left -
5294
+ containerRect.left +
5295
+ this.#viewport.scrollLeft +
5296
+ itemRect.width / 2;
5297
+ this.#viewport.scrollTo({
5298
+ left: itemCenter - this.#viewport.clientWidth / 2,
5299
+ behavior,
5300
+ });
5301
+ }
5302
+ this.#syncOverflow();
5303
+ });
5304
+ }
5305
+ }
5306
+ figDefineElement("fig-toolbelt", FigToolbelt);
5307
+
4801
5308
  /* Segmented Control */
4802
5309
  /**
4803
5310
  * A custom segment element for use within FigSegmentedControl.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "9.3.0",
3
+ "version": "9.4.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -45,6 +45,7 @@
45
45
  "dist/",
46
46
  ".cursor/skills/",
47
47
  "README.md",
48
+ "CHANGELOG.md",
48
49
  "LICENSE"
49
50
  ],
50
51
  "sideEffects": [
@@ -108,8 +109,5 @@
108
109
  "lightningcss": "^1.32.0",
109
110
  "lightningcss-cli": "^1.32.0",
110
111
  "playwright": "^1.58.2"
111
- },
112
- "peerDependencies": {
113
- "typescript": "^5.0.0"
114
112
  }
115
113
  }