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 + + + + +
+

Image

+ + +
+

Basic Usage

+
+ +
+
+
+
+ + 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

+ + +
+

Basic Usage

+
+ +
+
+
+ +
+
+
+ + +
+

Sizes

+
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+

Shape

+
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+

Vertical Orientation

+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ + +
+

Disabled Segments

+
+ +
+
+
+ +
+
+
+ + +
+

Custom Style

+
+ +
+
+
+ + +
+

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); } - -}); +};