diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 000000000..08d6d29d7
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "tabWidth": 4,
+ "printWidth": 120
+}
diff --git a/core/core.js b/core/core.js
index 5b39a8dae..fda152c52 100644
--- a/core/core.js
+++ b/core/core.js
@@ -1280,9 +1280,9 @@ Montage.defineProperty(Montage.prototype, "version", {
* This is an evolution to progressively remove the reliance on the additional
* serializable property set on JS PropertyDescritpors, and instead relay on setting in ObjectDescriptors
* property descriptors. The range of value is unusual as it is a blend of string and boolean....
- *
+ *
* Posible values: "reference" | "value" | "auto" | false,
- *
+ *
* @type {string | boolean} .
*/
@@ -1303,7 +1303,7 @@ Montage.defineProperty(Montage.prototype, "_buildSerializablePropertyNames", {
let _serializablePropertyNames,
legacySerializablePropertyNames = Montage.getSerializablePropertyNames(this);
-
+
Montage.defineProperty(Object.getPrototypeOf(this), "_serializablePropertyNames", {
value: (_serializablePropertyNames = this.objectDescriptor
? this.objectDescriptor.serializablePropertyDescriptors.map((aPropertyDescriptor) => {
@@ -1356,60 +1356,112 @@ Montage.defineProperty(Montage, "equals", {
});
/**
- * This method calls the method named with the identifier prefix if it exists.
- * Example: If the name parameter is "shouldDoSomething" and the caller's identifier is "bob", then
- * this method will try and call "bobShouldDoSomething"
+ * Calls the delegate method with the specified name if it exists on the delegate object.
+ * Uses caching to avoid repeated method lookups since delegate methods are unlikely to be removed dynamically.
*
- * TODO: Cache!!!! We're unlikely to remove a delegate method dynamically, so we should avoid checking all
- * that and just cache the function found, using a weak map, so don't retain delegates.
+ * The method first attempts to find a method with the pattern `{identifier}{Name}` on the delegate,
+ * where the first letter of the name parameter is capitalized. If not found, it falls back to
+ * looking for a method with the exact name.
*
* @function Montage#callDelegateMethod
- * @param {string} name
-*/
+ * @param {string} name - The name of the delegate method to call
+ * @param {...*} args - Additional arguments to pass to the delegate method
+ * @returns {*} The return value of the delegate method, or undefined if no method was found or no delegate exists
+ *
+ * @example
+ * // If this.identifier is "bob" and name is "shouldDoSomething"
+ * // This will try to call "bobShouldDoSomething" on the delegate
+ * const result = this.callDelegateMethod("shouldDoSomething", arg1, arg2);
+ */
Montage.defineProperty(Montage.prototype, "callDelegateMethod", {
value: function (name) {
- var delegate = this.delegate, delegateFunction;
+ const delegateFunction = this.getDelegateMethod(name);
- if (delegate) {
+ if (this.delegate && delegateFunction) {
+ const [, ...rest] = arguments;
+ return delegateFunction.call(this.delegate, ...rest);
+ }
+ }
+});
- var delegateFunctionName = this.identifier;
- delegateFunctionName += name.toCapitalized();
+/**
+ * Checks whether the delegate object has a method that responds to the specified name.
+ * This method uses the same lookup logic as callDelegateMethod and getDelegateMethod.
+ *
+ * @function Montage#respondsToDelegateMethod
+ * @param {string} name - The name of the delegate method to check for
+ * @returns {boolean} True if the delegate has a method that responds to the given name, false otherwise
+ *
+ * @example
+ * // Check if delegate can handle "shouldDoSomething"
+ * if (this.respondsToDelegateMethod("shouldDoSomething")) {
+ * this.callDelegateMethod("shouldDoSomething", data);
+ * }
+ */
+Montage.defineProperty(Montage.prototype, "respondsToDelegateMethod", {
+ value: function (name) {
+ return typeof this.getDelegateMethod(name) === FUNCTION;
+ }
+});
- if (
- typeof this.identifier === "string" &&
- typeof delegate[delegateFunctionName] === FUNCTION
- ) {
- delegateFunction = delegate[delegateFunctionName];
- } else if (typeof delegate[name] === FUNCTION) {
- delegateFunction = delegate[name];
- }
+// WeakMap to cache delegate methods - won't retain delegates when they're garbage collected
+const delegateMethodCache = new WeakMap();
- if (delegateFunction) {
- //Using modern JS:
- // Destructure the array to skip the first element
- const [, ...rest] = arguments;
- return delegateFunction.call(delegate, ...rest);
-
- // if(arguments.length === 2) {
- // return delegateFunction.call(delegate,arguments[1]);
- // }
- // else if(arguments.length === 3) {
- // return delegateFunction.call(delegate,arguments[1],arguments[2]);
- // }
- // else if(arguments.length === 4) {
- // return delegateFunction.call(delegate,arguments[1],arguments[2],arguments[3]);
- // }
- // else if(arguments.length === 5) {
- // return delegateFunction.call(delegate,arguments[1],arguments[2],arguments[3],arguments[4]);
- // }
- // else {
- // // remove first argument
- // ARRAY_PROTOTYPE.shift.call(arguments);
- // return delegateFunction.apply(delegate, arguments);
- // }
- }
+/**
+ * Retrieves a delegate method by name, using caching for performance optimization.
+ *
+ * The method searches for delegate methods in the following order:
+ * 1. First tries `{identifier}{Name}` where Name is the capitalized version of the name parameter
+ * 2. Falls back to the exact method name if the prefixed version doesn't exist
+ *
+ * Results are cached using a WeakMap to avoid repeated lookups while ensuring
+ * delegates can still be garbage collected when no longer referenced.
+ *
+ * @function Montage#getDelegateMethod
+ * @param {string} name - The name of the delegate method to retrieve
+ * @returns {Function|undefined} The delegate method function if found, undefined otherwise
+ *
+ * @example
+ * // If this.identifier is "list" and name is "shouldSelectItem"
+ * // This will look for "listShouldSelectItem" first, then "shouldSelectItem"
+ * const method = this.getDelegateMethod("shouldSelectItem");
+ * if (method) {
+ * method.call(this.delegate, item);
+ * }
+ */
+Montage.defineProperty(Montage.prototype, "getDelegateMethod", {
+ value: function (name) {
+ if (!this.delegate) return;
+
+ const delegate = this.delegate;
+ let delegateCache = delegateMethodCache.get(delegate);
+
+ if (!delegateCache) {
+ delegateCache = new Map();
+ delegateMethodCache.set(delegate, delegateCache);
}
- }
+
+ // Check if we already have the function cached
+ if (delegateCache.has(name)) return delegateCache.get(name);
+
+ let delegateFunctionName = this.identifier;
+ let delegateFunction;
+
+ delegateFunctionName += name.toCapitalized();
+
+ if (typeof this.identifier === "string" && typeof delegate[delegateFunctionName] === FUNCTION) {
+ delegateFunction = delegate[delegateFunctionName];
+ } else if (typeof delegate[name] === FUNCTION) {
+ delegateFunction = delegate[name];
+ }
+
+ // Cache the delegate function if it exists
+ if (delegateFunction) {
+ delegateCache.set(name, delegateFunction);
+ }
+
+ return delegateFunction;
+ },
});
// Property Changes
diff --git a/core/enum.js b/core/enum.js
index 56752f439..1d5595568 100644
--- a/core/enum.js
+++ b/core/enum.js
@@ -219,6 +219,12 @@ exports.Enum = Montage.specialize( /** @lends Enum# */ {
}
}
}
+ },
+
+ values: {
+ get: function () {
+ return this._members.map((member) => this[member]);
+ }
}
});
diff --git a/core/enums/visual-shape.js b/core/enums/visual-shape.js
new file mode 100644
index 000000000..34c19800e
--- /dev/null
+++ b/core/enums/visual-shape.js
@@ -0,0 +1,14 @@
+const { Enum } = require("../enum");
+
+const shapes = ["rectangle", "rounded", "pill"];
+const classNames = shapes.map((shape) => `mod--shape-${shape}`);
+
+/**
+ * @typedef {"rectangle"|"rounded"|'pill'} VisualShape
+ */
+const VisualShape = new Enum().initWithMembersAndValues(shapes, shapes);
+
+const VisualShapeClassNames = new Enum().initWithMembersAndValues(shapes, classNames);
+
+exports.VisualShapeClassNames = VisualShapeClassNames;
+exports.VisualShape = VisualShape;
diff --git a/core/enums/visual-size.js b/core/enums/visual-size.js
new file mode 100644
index 000000000..45f6bf5da
--- /dev/null
+++ b/core/enums/visual-size.js
@@ -0,0 +1,14 @@
+const { Enum } = require("../enum");
+
+const sizes = ["small", "medium", "large"];
+const classNames = sizes.map((size) => `mod--size-${size}`);
+
+/**
+ * @typedef {"small"|"medium"|'large'} VisualSize
+ */
+const VisualSize = new Enum().initWithMembersAndValues(sizes, sizes);
+
+const VisualSizeClassNames = new Enum().initWithMembersAndValues(sizes, classNames);
+
+exports.VisualSizeClassNames = VisualSizeClassNames;
+exports.VisualSize = VisualSize;
diff --git a/index.html b/index.html
index b26159d51..1abeecd0a 100644
--- a/index.html
+++ b/index.html
@@ -52,6 +52,8 @@
Components:
Toggle
TreeList
VirtualList
+ Image
+ Segmented bar
Managers:
Drag & Drop
diff --git a/ui/image.mod/image.js b/ui/image.mod/image.js
index e11258e6b..010e25d56 100644
--- a/ui/image.mod/image.js
+++ b/ui/image.mod/image.js
@@ -1,48 +1,59 @@
/**
- @module "mod/ui/native/image.mod"
- @requires mod/ui/component
- @requires mod/ui/native-control
-*/
-var Component = require("ui/component").Component;
+ * @module "mod/ui/native/image.mod"
+ * @requires mod/ui/component
+ * @requires mod/ui/native-control
+ */
+const { Component } = require("ui/component");
/**
* Wraps the a <img> element with binding support for its standard attributes.
- @class module:"mod/ui/native/image.mod".Image
- @extends module:mod/ui/control.Control
+ * @class module:"mod/ui/native/image.mod".Image
+ * @extends module:mod/ui/control.Control
*/
-exports.Image = Component.specialize({
- hasTemplate: {value: true }
+const Image = class Image extends Component {
+ hasTemplate = true;
+};
+
+/** @lends module:"mod/ui/native/image.mod".Image */
+Image.addAttributes({
+ /**
+ * A text description to display in place of the image.
+ * @type {string}
+ * @default null
+ */
+ alt: null,
+
+ /**
+ * The height of the image in CSS pixels.
+ * @type {number}
+ * @default null
+ */
+ height: null,
+
+ /**
+ * The URL where the image is located.
+ * @type {string}
+ * @default null
+ */
+ src: null,
+
+ /**
+ * The width of the image in CSS pixels.
+ * @type {number}
+ * @default null
+ */
+ width: null,
+
+ /**
+ * The loading strategy for the image.
+ * @type {string}
+ * @default "eager"
+ * @values ["eager", "lazy"]
+ */
+ loading: {
+ dataType: "string",
+ value: "eager",
+ },
});
-exports.Image.addAttributes(/** @lends module:"mod/ui/native/image.mod".Image */{
-
-/**
- A text description to display in place of the image.
- @type {string}
- @default null
-*/
- alt: null,
-
-/**
- The height of the image in CSS pixels.
- @type {number}
- @default null
-*/
- height: null,
-
-/**
- The URL where the image is located.
- @type {string}
- @default null
-*/
- src: null,
-
-/**
- The width of the image in CSS pixels.
- @type {number}
- @default null
-*/
- width: null
-
-
-});
+exports.Image = Image;
diff --git a/ui/image.mod/teach/assets/banana.svg b/ui/image.mod/teach/assets/banana.svg
new file mode 100644
index 000000000..8b4bd2c8f
--- /dev/null
+++ b/ui/image.mod/teach/assets/banana.svg
@@ -0,0 +1 @@
+
diff --git a/ui/image.mod/teach/index.html b/ui/image.mod/teach/index.html
new file mode 100644
index 000000000..ad0d6c62b
--- /dev/null
+++ b/ui/image.mod/teach/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Teach SegmentControl Mod
+
+
+
+
+
+
+
+
diff --git a/ui/image.mod/teach/package.json b/ui/image.mod/teach/package.json
new file mode 100644
index 000000000..98815d77d
--- /dev/null
+++ b/ui/image.mod/teach/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "teach-segmented-bar-mod",
+ "private": true,
+ "dependencies": {
+ "mod": "*"
+ },
+ "mappings": {
+ "mod": "../../../"
+ }
+}
diff --git a/ui/image.mod/teach/ui/main.mod/main.css b/ui/image.mod/teach/ui/main.mod/main.css
new file mode 100644
index 000000000..b382bbae7
--- /dev/null
+++ b/ui/image.mod/teach/ui/main.mod/main.css
@@ -0,0 +1,51 @@
+body {
+ font-family: -apple-system, Roboto, sans-serif;
+ background-color: #fafafa;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+
+ .card {
+ margin: 40px 0;
+ padding: 16px;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ h2 {
+ margin: 0;
+ color: #333;
+ }
+
+ label {
+ min-width: 128px;
+ font-weight: 500;
+ }
+ }
+
+ .row {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ }
+
+ .column {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ align-items: flex-start;
+
+ &.stretch {
+ align-items: stretch;
+ }
+ }
+
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+ }
+}
diff --git a/ui/image.mod/teach/ui/main.mod/main.html b/ui/image.mod/teach/ui/main.mod/main.html
new file mode 100644
index 000000000..b1bd25d11
--- /dev/null
+++ b/ui/image.mod/teach/ui/main.mod/main.html
@@ -0,0 +1,38 @@
+
+
+
+
+ Teach Image Mod
+
+
+
+
+
+
+
diff --git a/ui/image.mod/teach/ui/main.mod/main.js b/ui/image.mod/teach/ui/main.mod/main.js
new file mode 100644
index 000000000..15dfb7465
--- /dev/null
+++ b/ui/image.mod/teach/ui/main.mod/main.js
@@ -0,0 +1,5 @@
+const { Component } = require("mod/ui/component");
+
+exports.Main = class Main extends Component {
+
+};
diff --git a/ui/placeholder.mod/placeholder.js b/ui/placeholder.mod/placeholder.js
index 34eb069cc..876874377 100755
--- a/ui/placeholder.mod/placeholder.js
+++ b/ui/placeholder.mod/placeholder.js
@@ -111,6 +111,13 @@ var Placeholder = exports.Placeholder = Slot.specialize({
oldExitDocument = component.exitDocument;
component.data = self.data;
+
+ // @Benoit FIXME: remove this when the component is properly bound
+ // START TESTING
+ component.defineBindings(self.componentValues);
+ component.src = self.componentValues.src['='];
+ // END TESTING
+
component.exitDocument = function () {
if (oldExitDocument) {
oldExitDocument.call(component);
diff --git a/ui/segmented-control.mod/segment.mod/segment.css b/ui/segmented-control.mod/segment.mod/segment.css
new file mode 100644
index 000000000..7730efb02
--- /dev/null
+++ b/ui/segmented-control.mod/segment.mod/segment.css
@@ -0,0 +1,108 @@
+:root {
+ /* Segment */
+ --mod-segment-text: rgba(0, 0, 0, 0.65);
+ --mod-segment-transition: color 0.25s ease-out, background-color 0.25s ease-out;
+
+ /* Segment heights */
+ --mod-segment-height-small: 32px;
+ --mod-segment-height-medium: 36px;
+ --mod-segment-height-large: 44px;
+
+ /* Segment Hover State */
+ --mod-segment-hover-background: rgba(229, 231, 235, 0.55);
+ --mod-segment-hover-text: rgba(0, 0, 0, 1);
+
+ /* Segment Active State */
+ --mod-segment-active-background: rgba(229, 231, 235, 1);
+ --mod-segment-active-text: rgba(0, 0, 0, 1);
+
+ /* Segment Border Sizes */
+ --mod-segment-border-radius-small: 6px;
+ --mod-segment-border-radius-medium: 8px;
+ --mod-segment-border-radius-large: 12px;
+
+ /* Segment sizes */
+ --mod-segment-padding-small: 4px 8px;
+ --mod-segment-padding-medium: 8px 12px;
+ --mod-segment-padding-large: 12px 20px;
+ --mod-segment-font-size-small: 12px;
+ --mod-segment-font-size-medium: 13px;
+ --mod-segment-font-size-large: 16px;
+}
+
+/* Dark Theme Variables */
+@media (prefers-color-scheme: dark) {
+ :root {
+ /* Segment - Dark Theme */
+ --mod-segment-text: rgba(160, 160, 160, 0.65);
+
+ /* Segment Hover State - Dark Theme */
+ --mod-segment-hover-background: rgba(255, 255, 255, 0.08);
+ --mod-segment-hover-text: rgba(255, 255, 255, 0.9);
+
+ /* Segment Active State - Dark Theme */
+ --mod-segment-active-background: rgba(255, 255, 255, 0.12);
+ --mod-segment-active-text: rgba(255, 255, 255, 1);
+ }
+}
+
+@scope (.ModSegment) {
+ :scope {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--mod-segment-text);
+ cursor: pointer;
+ transition: var(--mod-segment-transition);
+ z-index: 2;
+ box-sizing: border-box;
+ white-space: nowrap;
+ -webkit-user-select: none;
+ user-select: none;
+ outline: none;
+ border: none;
+ background: transparent;
+ font-family: inherit;
+ font-size: inherit;
+ gap: 8px;
+
+ &:hover:not(.mod--selected):not(.mod--disabled) {
+ background-color: var(--mod-segment-hover-background);
+ color: var(--mod-segment-hover-text);
+ }
+
+ &:active:not(.mod--selected):not(.mod--disabled) {
+ background-color: var(--mod-segment-active-background);
+ color: var(--mod-segment-active-text);
+ }
+
+ &.mod--selected {
+ color: var(--mod-segmented-control-thumb-text);
+ }
+
+ &.mod--disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+
+ .ModSegment-leading,
+ .ModSegment-trailing,
+ .ModSegment-label {
+ &:is(:empty) {
+ display: none;
+ }
+ }
+
+ .ModSegment-leading,
+ .ModSegment-trailing {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ img {
+ -webkit-user-drag: none;
+ }
+ }
+ }
+}
diff --git a/ui/segmented-control.mod/segment.mod/segment.html b/ui/segmented-control.mod/segment.mod/segment.html
new file mode 100644
index 000000000..4f1ecf4c4
--- /dev/null
+++ b/ui/segmented-control.mod/segment.mod/segment.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/segmented-control.mod/segment.mod/segment.js b/ui/segmented-control.mod/segment.mod/segment.js
new file mode 100644
index 000000000..60176d1a7
--- /dev/null
+++ b/ui/segmented-control.mod/segment.mod/segment.js
@@ -0,0 +1,137 @@
+const { Component } = require("ui/component");
+
+/**
+ * A segment component representing an individual option in a segmented control.
+ * @class Segment
+ * @extends Component
+ */
+const Segment = class Segment extends Component {
+ /**
+ * The label text displayed in the segment
+ * @type {string}
+ */
+ _label = "";
+
+ get label() {
+ return this._label;
+ }
+
+ set label(value) {
+ if (this._label === value) return;
+ this._label = value;
+ this.needsDraw = true;
+ }
+
+ /**
+ * The value associated with this segment
+ * @type {*}
+ */
+ _value = null;
+
+ get value() {
+ return this._value;
+ }
+
+ set value(val) {
+ if (this._value === val) return;
+ this._value = val;
+ }
+
+ /**
+ * Whether this segment is currently selected
+ * @type {boolean}
+ */
+ _selected = false;
+
+ get selected() {
+ return this._selected;
+ }
+
+ set selected(value) {
+ if (this._selected === value) return;
+ this._selected = Boolean(value);
+ this.needsDraw = true;
+ }
+
+ /**
+ * Whether this segment is disabled
+ * @type {boolean}
+ */
+ _disabled = false;
+
+ get disabled() {
+ return this._disabled;
+ }
+
+ set disabled(value) {
+ if (this._disabled === value) return;
+ this._disabled = Boolean(value);
+ this.needsDraw = true;
+ }
+
+ /**
+ * The segment option object containing label, value, and disabled state
+ * @type {Object}
+ */
+ _option = null;
+
+ get option() {
+ return this._option;
+ }
+
+ set option(value) {
+ if (this._option === value) return;
+
+ this._option = value;
+
+ if (value) {
+ this.label = value.label;
+ this.value = value.value;
+ this.disabled = Boolean(value.disabled);
+ }
+ }
+
+ draw() {
+ this._applySelectedClass();
+ this._applyDisabledClass();
+ this._updateTabIndex();
+ }
+
+ /**
+ * Applies or removes the selected CSS class
+ * @private
+ */
+ _applySelectedClass() {
+ if (this._selected) {
+ this.element.classList.add("mod--selected");
+ } else {
+ this.element.classList.remove("mod--selected");
+ }
+ }
+
+ /**
+ * Applies or removes the disabled CSS class
+ * @private
+ */
+ _applyDisabledClass() {
+ if (this._disabled) {
+ this.element.classList.add("mod--disabled");
+ } else {
+ this.element.classList.remove("mod--disabled");
+ }
+ }
+
+ /**
+ * Updates the tabindex based on disabled state
+ * @private
+ */
+ _updateTabIndex() {
+ this.element.tabIndex = this._disabled ? -1 : 0;
+ }
+};
+
+if (window.MontageElement) {
+ MontageElement.define("mod-segment", Segment);
+}
+
+exports.Segment = Segment;
diff --git a/ui/segmented-control.mod/segmented-control.css b/ui/segmented-control.mod/segmented-control.css
new file mode 100644
index 000000000..a1cf90a1e
--- /dev/null
+++ b/ui/segmented-control.mod/segmented-control.css
@@ -0,0 +1,222 @@
+:root {
+ --mod-segmented-control-background: rgb(243, 244, 246);
+ --mod-segmented-control-padding: 2px;
+
+ /* Thumb */
+ --mod-segmented-control-thumb-background-color: none;
+ --mod-segmented-control-thumb-background-gradient: linear-gradient(356deg,rgba(253, 253, 253, 1) 0%, rgba(255, 255, 255, 1) 100%);
+ --mod-segmented-control-thumb-border-color: rgba(255, 255, 255, 1);
+ --mod-segmented-control-thumb-shadow: 0px 0px 3px 1px rgba(0, 0, 0, 0.04);
+ --mod-segmented-control-thumb-text: rgba(0, 0, 0, 0.85);
+ --mod-segmented-control-thumb-transition: all 0.35s cubic-bezier(0.375, 0.075, 0, 1.115);
+}
+
+/* Dark Theme Variables */
+@media (prefers-color-scheme: dark) {
+ :root {
+ /* Segmented Control Background */
+ --mod-segmented-control-background: rgba(0, 0, 0, 0.8);
+
+ /* Thumb - Dark Theme */
+ --mod-segmented-control-thumb-background: rgba(255, 255, 255, 0.08);
+ --mod-segmented-control-thumb-border-color: rgba(255, 255, 255, 0.04);
+ --mod-segmented-control-thumb-shadow: 0px 0px 4px 1px rgba(0, 0, 0, 0.3);
+ --mod-segmented-control-thumb-text: rgba(255, 255, 255, 0.95);
+ }
+}
+
+@scope (.ModSegmentedControl) {
+ :scope {
+ display: inline-flex;
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ > .ModSegmentedControl-container {
+ display: flex;
+ background-color: var(--mod-segmented-control-background);
+ padding: var(--mod-segmented-control-padding);
+ position: relative;
+ flex: 1;
+
+ > .ModSegmentedControl-thumb {
+ position: absolute;
+ top: var(--mod-segmented-control-padding);
+ left: var(--mod-segmented-control-padding);
+ background-color: var(--mod-segmented-control-thumb-background-color);
+ background-image: var(--mod-segmented-control-thumb-background-gradient);
+ backdrop-filter: blur(10px);
+ border: 2px solid var(--mod-segmented-control-thumb-border-color);
+ box-sizing: border-box;
+ box-shadow: var(--mod-segmented-control-thumb-shadow);
+ z-index: 1;
+ pointer-events: none;
+ }
+
+ > .ModSegmentedControl-segments {
+ display: flex;
+ flex-wrap: nowrap;
+ overflow: hidden;
+ position: relative;
+ flex: 1;
+ justify-content: space-around;
+
+ > .ModSegmentedControl-segment {
+ flex: 1;
+ }
+ }
+ }
+
+ &.mod--disabled {
+ opacity: 0.6;
+ pointer-events: none;
+ cursor: not-allowed;
+ }
+
+ &.mod--animate {
+ /* This class is added after the first draw */
+ /* FIXME: @benoit: maybe a better way of doing that, draw gates maybe ? */
+ > .ModSegmentedControl-container {
+ > .ModSegmentedControl-thumb {
+ transition: var(--mod-segmented-control-thumb-transition);
+ }
+ }
+ }
+
+ /* Orientation */
+
+ &.mod--horizontal {
+ > .ModSegmentedControl-container {
+ > .ModSegmentedControl-thumb {
+ height: calc(100% - var(--mod-segmented-control-padding) * 2);
+ }
+
+ > .ModSegmentedControl-segments {
+ flex-direction: row;
+ }
+ }
+ }
+
+ &.mod--vertical {
+ > .ModSegmentedControl-container {
+ > .ModSegmentedControl-thumb {
+ width: calc(100% - var(--mod-segmented-control-padding) * 2);
+ }
+
+ > .ModSegmentedControl-segments {
+ flex-direction: column;
+ width: 100%;
+
+ > .ModSegmentedControl-segment {
+ width: 100%;
+ text-align: left;
+ }
+ }
+ }
+ }
+
+ /* Size */
+
+ &.mod--size-small {
+ > .ModSegmentedControl-container {
+ .ModSegmentedControl-segment {
+ padding: var(--mod-segment-padding-small);
+ min-height: var(--mod-segment-height-small);
+ font-size: var(--mod-segment-font-size-small);
+ }
+ }
+ }
+
+ &.mod--size-medium {
+ > .ModSegmentedControl-container {
+ .ModSegmentedControl-segment {
+ padding: var(--mod-segment-padding-medium);
+ min-height: var(--mod-segment-height-medium);
+ font-size: var(--mod-segment-font-size-medium);
+ }
+ }
+ }
+
+ &.mod--size-large {
+ > .ModSegmentedControl-container {
+ .ModSegmentedControl-segment {
+ padding: var(--mod-segment-padding-large);
+ min-height: var(--mod-segment-height-large);
+ font-size: var(--mod-segment-font-size-large);
+ }
+ }
+ }
+
+ /* Shapes */
+
+ &.mod--shape-pill {
+ &.mod--vertical {
+ /* TODO: Consider handling this logic in JavaScript for greater resilience and flexibility */
+ &.mod--size-small {
+ > .ModSegmentedControl-container {
+ border-radius: calc(var(--mod-segment-height-small) / 2 + var(--mod-segmented-control-padding));
+ }
+ }
+
+ &.mod--size-medium {
+ > .ModSegmentedControl-container {
+ border-radius: calc(var(--mod-segment-height-medium) / 2 + var(--mod-segmented-control-padding));
+ }
+ }
+
+ &.mod--size-large {
+ > .ModSegmentedControl-container {
+ border-radius: calc(var(--mod-segment-height-large) / 2 + var(--mod-segmented-control-padding));
+ }
+ }
+ }
+
+ &.mod--size-small,
+ &.mod--size-medium,
+ &.mod--size-large {
+ > .ModSegmentedControl-container {
+ /* 50% doesn't work for rectangle shape */
+ border-radius: 99999px;
+
+ > .ModSegmentedControl-thumb,
+ > .ModSegmentedControl-segments > .ModSegmentedControl-segment {
+ border-radius: 99999px;
+ }
+ }
+ }
+ }
+
+ &.mod--shape-rounded {
+ &.mod--size-small {
+ > .ModSegmentedControl-container {
+ border-radius: var(--mod-segment-border-radius-small);
+
+ > .ModSegmentedControl-thumb,
+ > .ModSegmentedControl-segments > .ModSegmentedControl-segment {
+ border-radius: calc(var(--mod-segment-border-radius-small) - var(--mod-segmented-control-padding));
+ }
+ }
+ }
+
+ &.mod--size-medium {
+ > .ModSegmentedControl-container {
+ border-radius: var(--mod-segment-border-radius-medium);
+
+ > .ModSegmentedControl-thumb,
+ > .ModSegmentedControl-segments > .ModSegmentedControl-segment {
+ border-radius: calc(var(--mod-segment-border-radius-medium) - var(--mod-segmented-control-padding));
+ }
+ }
+ }
+
+ &.mod--size-large {
+ > .ModSegmentedControl-container {
+ border-radius: var(--mod-segment-border-radius-large);
+
+ > .ModSegmentedControl-thumb,
+ > .ModSegmentedControl-segments > .ModSegmentedControl-segment {
+ border-radius: calc(var(--mod-segment-border-radius-large) - var(--mod-segmented-control-padding));
+ }
+ }
+ }
+ }
+}
diff --git a/ui/segmented-control.mod/segmented-control.html b/ui/segmented-control.mod/segmented-control.html
new file mode 100644
index 000000000..dc5aec37f
--- /dev/null
+++ b/ui/segmented-control.mod/segmented-control.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/segmented-control.mod/segmented-control.js b/ui/segmented-control.mod/segmented-control.js
new file mode 100644
index 000000000..c0cbaa597
--- /dev/null
+++ b/ui/segmented-control.mod/segmented-control.js
@@ -0,0 +1,291 @@
+const { VisualShape, VisualShapeClassNames } = require("core/enums/visual-shape");
+const { VisualSize, VisualSizeClassNames } = require("core/enums/visual-size");
+const { VisualOrientation } = require("core/enums/visual-orientation");
+const { Component } = require("ui/component");
+
+/**
+ * A segmented control component that allows users to select from multiple options.
+ * Displays options as segments with a sliding thumb indicator for the selected option.
+ *
+ * @class SegmentedControl
+ * @extends Component
+ */
+const SegmentedControl = class SegmentedControl extends Component {
+ // FIXME: @Benoit workaround: until `removeRangeAtPathChangeListener` is implemented
+ _cancelHandleOptionsChange = null;
+
+ /**
+ * The currently selected option value
+ * @type {*}
+ */
+ selection = null;
+
+ /**
+ * The disabled state of the segmented control
+ * @returns {boolean} True if disabled, false otherwise
+ */
+ _disabled = false;
+
+ get disabled() {
+ return this._disabled;
+ }
+
+ set disabled(value) {
+ if (this._disabled === value) return;
+
+ this._disabled = Boolean(value);
+ this.needsDraw = true;
+ }
+
+ /**
+ * The array of options for the segmented control
+ * @returns {Array} The options array
+ */
+ _options = [];
+
+ get options() {
+ return this._options;
+ }
+
+ set options(value) {
+ if (this._options === value) return;
+
+ if (Array.isArray(value)) {
+ this._options = value;
+ } else {
+ console.warn("Options must be an array.");
+ this._options = [];
+ }
+
+ this._normalizedOptions = this._normalizeOptions();
+ this.needsDraw = true;
+ }
+
+ /**
+ * The orientation of the segmented control
+ * @returns {string} The current orientation (horizontal or vertical)
+ */
+ _orientation = VisualOrientation.horizontal;
+
+ get orientation() {
+ return this._orientation;
+ }
+
+ set orientation(value) {
+ if (this._orientation === value) return;
+
+ if (!VisualOrientation.members.includes(value)) {
+ console.warn("Invalid orientation value. Defaulting to horizontal.");
+ this._orientation = VisualOrientation.horizontal;
+ } else {
+ this._orientation = VisualOrientation[value];
+ }
+
+ this.needsDraw = true;
+ }
+
+ /**
+ * The size of the segmented control
+ * @returns {string} The current size
+ */
+ _size = VisualSize.medium;
+
+ get size() {
+ return this._size;
+ }
+
+ set size(value) {
+ if (this._size === value) return;
+
+ if (!VisualSize.members.includes(value)) {
+ console.warn("Invalid size value. Defaulting to medium.");
+ this._size = VisualSize.medium;
+ } else {
+ this._size = VisualSize[value];
+ }
+
+ this.needsDraw = true;
+ }
+
+ /**
+ * The shape of the segmented control
+ * @returns {string} The current shape
+ */
+ _shape = VisualShape.rounded;
+
+ get shape() {
+ return this._shape;
+ }
+
+ set shape(value) {
+ if (this._shape === value) return;
+
+ if (!VisualShape.members.includes(value)) {
+ console.warn("Invalid shape value. Defaulting to 'rounded'.");
+ this._shape = VisualShape.rounded;
+ } else {
+ this._shape = VisualShape[value];
+ }
+
+ this.needsDraw = true;
+ }
+
+ enterDocument() {
+ this._cancelHandleOptionsChange = this.addRangeAtPathChangeListener("_options", this, "handleOptionsChange");
+ this.addPathChangeListener("_selectedOption", this, "handleSelectionChange");
+ }
+
+ exitDocument() {
+ this.removePathChangeListener("_selectedOption", this);
+ this._cancelHandleOptionsChange?.();
+ }
+
+ slotContentDidFirstDraw(slot) {
+ this.needsDraw = true;
+ }
+
+ /**
+ * Handles changes to the options array.
+ * Re-normalizes options and triggers a redraw.
+ */
+ handleOptionsChange() {
+ this._normalizedOptions = this._normalizeOptions();
+ this.needsDraw = true;
+ }
+
+ /**
+ * Handles selection changes and dispatches a change event
+ * @param {Object} option - The selected option object
+ */
+ handleSelectionChange(option) {
+ const event = new CustomEvent("change", {
+ detail: option,
+ bubbles: true,
+ });
+
+ this.selection = option?.value || null;
+ this.dispatchEvent(event);
+ this.needsDraw = true;
+ }
+
+ draw() {
+ // Apply classes based on shape, size, and orientation
+ this._applyOrientationClasses();
+ this._applyDisabledClass();
+ this._applyShapeClasses();
+ this._applySizeClasses();
+
+ // Move the thumb to the selected segment if available
+ const { selectedIterations = [] } = this.templateObjects?.segments ?? {};
+ const [selectedIteration] = selectedIterations;
+
+ if (selectedIteration) {
+ this.thumbElement.style.display = "block";
+ const segmentElement = selectedIteration.firstElement;
+ this._moveThumbToSegment(segmentElement);
+ } else {
+ // If no segment is selected, hide the thumb
+ this.thumbElement.style.display = "none";
+ }
+ }
+
+ didDraw() {
+ if (!this._completedFirstDraw) {
+ // Allow animations after first draw
+ requestAnimationFrame(() => this.element?.classList.add("mod--animate"));
+ }
+ }
+
+ /**
+ * Normalizes the options array to ensure consistent object structure
+ * @private
+ * @returns {Array} Array of normalized option objects with label, value, and disabled properties
+ */
+ _normalizeOptions() {
+ return this._options.map((option) => {
+ if (typeof option === "string" || typeof option === "number") {
+ return { label: option, value: option, disabled: false };
+ }
+
+ return {
+ ...option,
+ disabled: option.disabled || false,
+ label: option.label,
+ value: option.value,
+ };
+ });
+ }
+
+ /**
+ * Moves the thumb element to match the position and size of the selected segment
+ * @private
+ * @param {HTMLElement} segmentElement - The DOM element of the selected segment
+ */
+ _moveThumbToSegment(segmentElement) {
+ if (!segmentElement || !this.thumbElement) return;
+
+ const height = segmentElement.offsetHeight;
+ const width = segmentElement.offsetWidth;
+
+ if (width === 0 || height === 0) {
+ console.warn("Segment element has zero width or height, cannot position thumb.");
+ return;
+ }
+
+ this.thumbElement.style.height = `${height}px`;
+ this.thumbElement.style.width = `${width}px`;
+
+ if (this._orientation === VisualOrientation.horizontal) {
+ const left = segmentElement.offsetLeft;
+ this.thumbElement.style.transform = `translate3d(${left}px, 0, 0)`;
+ } else {
+ const top = segmentElement.offsetTop;
+ this.thumbElement.style.transform = `translate3d(0, ${top}px, 0)`;
+ }
+ }
+
+ /**
+ * Applies CSS classes based on the current orientation
+ * @private
+ */
+ _applyOrientationClasses() {
+ this.element.classList.remove(...Object.values(VisualOrientation.values));
+ this.element.classList.add(this._orientation);
+ }
+
+ /**
+ * Applies CSS classes based on the current shape
+ * @private
+ */
+ _applyShapeClasses() {
+ this.element.classList.remove(...Object.values(VisualShapeClassNames.values));
+ this.element.classList.add(VisualShapeClassNames[this._shape]);
+ }
+
+ /**
+ * Applies CSS classes based on the current size
+ * @private
+ */
+ _applySizeClasses() {
+ this.element.classList.remove(...Object.values(VisualSizeClassNames.values));
+ this.element.classList.add(VisualSizeClassNames[this._size]);
+ }
+
+ /**
+ * Applies or removes the disabled CSS class based on the disabled state
+ * @private
+ */
+ _applyDisabledClass() {
+ if (this.disabled) {
+ this.element.classList.add("mod--disabled");
+ } else {
+ this.element.classList.remove("mod--disabled");
+ }
+ }
+};
+
+if (window.MontageElement) {
+ MontageElement.define("segmented-control-mod", SegmentedControl);
+}
+
+exports.SegmentedControl = SegmentedControl;
diff --git a/ui/segmented-control.mod/teach/assets/apple.svg b/ui/segmented-control.mod/teach/assets/apple.svg
new file mode 100644
index 000000000..2e14fe35a
--- /dev/null
+++ b/ui/segmented-control.mod/teach/assets/apple.svg
@@ -0,0 +1 @@
+
diff --git a/ui/segmented-control.mod/teach/assets/banana.svg b/ui/segmented-control.mod/teach/assets/banana.svg
new file mode 100644
index 000000000..8b4bd2c8f
--- /dev/null
+++ b/ui/segmented-control.mod/teach/assets/banana.svg
@@ -0,0 +1 @@
+
diff --git a/ui/segmented-control.mod/teach/assets/grappe.svg b/ui/segmented-control.mod/teach/assets/grappe.svg
new file mode 100644
index 000000000..59ca18c14
--- /dev/null
+++ b/ui/segmented-control.mod/teach/assets/grappe.svg
@@ -0,0 +1 @@
+
diff --git a/ui/segmented-control.mod/teach/index.html b/ui/segmented-control.mod/teach/index.html
new file mode 100644
index 000000000..ad0d6c62b
--- /dev/null
+++ b/ui/segmented-control.mod/teach/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Teach SegmentControl Mod
+
+
+
+
+
+
+
+
diff --git a/ui/segmented-control.mod/teach/package.json b/ui/segmented-control.mod/teach/package.json
new file mode 100644
index 000000000..98815d77d
--- /dev/null
+++ b/ui/segmented-control.mod/teach/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "teach-segmented-bar-mod",
+ "private": true,
+ "dependencies": {
+ "mod": "*"
+ },
+ "mappings": {
+ "mod": "../../../"
+ }
+}
diff --git a/ui/segmented-control.mod/teach/ui/main.mod/main.css b/ui/segmented-control.mod/teach/ui/main.mod/main.css
new file mode 100644
index 000000000..106e22d18
--- /dev/null
+++ b/ui/segmented-control.mod/teach/ui/main.mod/main.css
@@ -0,0 +1,96 @@
+body {
+ font-family: -apple-system, Roboto, sans-serif;
+ background-color: #fafafa;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+
+ .card {
+ margin: 40px 0;
+ padding: 16px;
+ background: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ h2 {
+ margin: 0;
+ color: #333;
+ }
+
+ label {
+ min-width: 128px;
+ font-weight: 500;
+ }
+ }
+
+ .row {
+ display: flex;
+ gap: 24px;
+
+ &.center {
+ align-items: center;
+ justify-content: center;
+ }
+
+ &.justify {
+ justify-content: space-between;
+ }
+
+ &.space-around {
+ justify-content: space-around;
+ }
+ }
+
+ .column {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 24px;
+
+ &.stretch {
+ align-items: stretch;
+ }
+
+ &.center {
+ align-items: center;
+ }
+ }
+
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+ }
+
+ .customStyleSegmentedControl {
+ /* Container */
+ --mod-segmented-control-background: rgba(71, 85, 105, 0.12);
+
+ /* Thumb */
+ --mod-segmented-control-thumb-background-color: rgba(71, 85, 105, 0.85);
+ --mod-segmented-control-thumb-background-gradient: none;
+ --mod-segmented-control-thumb-border-color: rgba(71, 85, 105, 0.15);
+ --mod-segmented-control-thumb-shadow: 0px 0px 5px 1px rgba(71, 85, 105, 0.2);
+ --mod-segmented-control-thumb-text: rgba(255, 255, 255, 0.95);
+
+ /* Segment */
+ --mod-segment-text: rgba(71, 85, 105, 0.75);
+
+ /* Segment Hover State */
+ --mod-segment-hover-background: rgba(71, 85, 105, 0.08);
+ --mod-segment-hover-text: rgba(71, 85, 105, 0.9);
+
+ /* Segment Active State */
+ --mod-segment-active-background: rgba(71, 85, 105, 0.14);
+ --mod-segment-active-text: rgba(71, 85, 105, 1);
+
+ --mod-segment-height-small: 28px;
+
+ .ModSegmentedControl {
+ flex: 1;
+ }
+ }
+}
diff --git a/ui/segmented-control.mod/teach/ui/main.mod/main.html b/ui/segmented-control.mod/teach/ui/main.mod/main.html
new file mode 100644
index 000000000..8a69f5f1e
--- /dev/null
+++ b/ui/segmented-control.mod/teach/ui/main.mod/main.html
@@ -0,0 +1,324 @@
+
+
+
+
+ Teach SegmentedBar Mod
+
+
+
+
+
+
Segmented Control
+
+
+
+
+
+
+
+
+
+
+
+
+
Vertical Orientation
+
+
+
+
+
+
Disabled Segments
+
+
+
+
+
+
+
+
+
+
Icon Segmented Control
+
+
+
+
+
diff --git a/ui/segmented-control.mod/teach/ui/main.mod/main.js b/ui/segmented-control.mod/teach/ui/main.mod/main.js
new file mode 100644
index 000000000..ae1a74557
--- /dev/null
+++ b/ui/segmented-control.mod/teach/ui/main.mod/main.js
@@ -0,0 +1,16 @@
+const { Component } = require("mod/ui/component");
+
+exports.Main = class Main extends Component {
+ stringOptions = ["Apple", "Banana", "Cherry", "Orange"];
+ objectOptions = [
+ { label: "Daily", value: "daily" },
+ { label: "Weekly", value: "weekly" },
+ { label: "Monthly", value: "monthly" }
+ ];
+
+ mixedDisabledOptions = [
+ { label: "Option 1", value: "option1" },
+ { label: "Option 2", value: "option2", disabled: true },
+ { label: "Option 3", value: "option3" }
+ ];
+};
diff --git a/ui/slot.mod/slot.js b/ui/slot.mod/slot.js
index ede32f275..c0e631d8a 100644
--- a/ui/slot.mod/slot.js
+++ b/ui/slot.mod/slot.js
@@ -1,8 +1,8 @@
/**
- @module "mod/ui/slot.mod"
- @requires mod/ui/component
-*/
-var Component = require("../component").Component;
+ * @module "mod/ui/slot.mod"
+ * @requires mod/ui/component
+ */
+const { Component } = require("../component");
/**
* @class Slot
@@ -10,13 +10,7 @@ var Component = require("../component").Component;
* other component.
* @extends Component
*/
-exports.Slot = Component.specialize( /** @lends Slot.prototype # */ {
-
- hasTemplate: {
- enumerable: false,
- value: false
- },
-
+exports.Slot = class Slot extends Component {
/**
* An optional helper object. The slot consults
* `delegate.slotElementForComponent(component):Element` if available for
@@ -28,74 +22,75 @@ exports.Slot = Component.specialize( /** @lends Slot.prototype # */ {
* @type {?Object}
* @default null
*/
- delegate: {
- value: null
- },
+ delegate = null;
- _content: {
- value: null
- },
+ _content = null;
- enterDocument:{
- value:function (firstTime) {
- if (firstTime) {
- this.element.classList.add("slot-mod");
- }
- }
- },
+ get hasTemplate() {
+ return false;
+ }
/**
* The component that resides in this slot and in its place in the
* template.
* @type {Component}
* @default null
- */
- content: {
- get: function () {
- return this._content;
- },
- set: function (value) {
- var element,
- content;
+ */
+ get content() {
+ return this._content;
+ }
- if (value && typeof value.needsDraw !== "undefined") {
- content = this._content;
+ set content(value) {
+ let element;
- // If the incoming content was a component; make sure it has an element before we say it needs to draw
- if (!value.element) {
- element = document.createElement("div");
+ if (value && typeof value.needsDraw !== "undefined") {
+ // If the incoming content was a component;
+ // make sure it has an element before we say it needs to draw
+ if (!value.element) {
+ element = document.createElement("div");
- if (this.delegate && typeof this.delegate.slotElementForComponent === "function") {
- element = this.delegate.slotElementForComponent(this, value, element);
- }
- value.element = element;
- } else {
- element = value.element;
+ if (this.respondsToDelegateMethod("slotElementForComponent")) {
+ element = this.callDelegateMethod("slotElementForComponent", this, value, element);
}
- // The child component will need to draw; this may trigger a draw for the slot itself
- this.domContent = element;
- value.needsDraw = true;
-
+ value.element = element;
} else {
- this.domContent = value;
+ element = value.element;
}
- this._content = value;
- this.needsDraw = true;
+ // The child component will need to draw;
+ // this may trigger a draw for the slot itself
+ this.domContent = element;
+ value.needsDraw = true;
+ } else {
+ this.domContent = value;
+ }
+
+ this._content = value;
+ this.needsDraw = true;
+ }
+
+ enterDocument(firstTime) {
+ if (firstTime) {
+ this.element.classList.add("slot-mod");
}
- },
+
+ this.addEventListener("firstDraw", this, false);
+ }
+
+ exitDocument() {
+ this.removeEventListener("firstDraw", this, false);
+ }
+
+ handleFirstDraw() {
+ this.callDelegateMethod("slotContentDidFirstDraw", this);
+ }
/**
* Informs the `delegate` that `slotDidSwitchContent(slot)`
* @function
*/
- contentDidChange: {
- value: function () {
- if (this.delegate && typeof this.delegate.slotDidSwitchContent === "function") {
- this.delegate.slotDidSwitchContent(this);
- }
- }
+ contentDidChange() {
+ this.callDelegateMethod("slotDidSwitchContent", this);
}
-
-});
+};