From aaa5f54929537388e60ec4deb38f2da248f5adbd Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 14 Mar 2024 16:57:20 +0100 Subject: [PATCH] [SimplePrimitives] Add accordion --- .../Aardvark.UI.Primitives.fsproj | 1 + .../Primitives/SimplePrimitives.fs | 99 +++++- .../resources/accordion.js | 65 ++++ src/Examples (dotnetcore)/23 - Inputs/App.fs | 292 +++++++++--------- 4 files changed, 303 insertions(+), 154 deletions(-) create mode 100644 src/Aardvark.UI.Primitives/resources/accordion.js diff --git a/src/Aardvark.UI.Primitives/Aardvark.UI.Primitives.fsproj b/src/Aardvark.UI.Primitives/Aardvark.UI.Primitives.fsproj index 778e3e02..671ee9c2 100644 --- a/src/Aardvark.UI.Primitives/Aardvark.UI.Primitives.fsproj +++ b/src/Aardvark.UI.Primitives/Aardvark.UI.Primitives.fsproj @@ -84,6 +84,7 @@ + diff --git a/src/Aardvark.UI.Primitives/Primitives/SimplePrimitives.fs b/src/Aardvark.UI.Primitives/Primitives/SimplePrimitives.fs index fdfc4be9..3aa97014 100644 --- a/src/Aardvark.UI.Primitives/Primitives/SimplePrimitives.fs +++ b/src/Aardvark.UI.Primitives/Primitives/SimplePrimitives.fs @@ -619,6 +619,81 @@ module SimplePrimitives = ) ) + let private accordionImpl (toggle: Option int -> 'msg>) (active: Choice, aval>) + (attributes: AttributeMap<'msg>) (sections: alist>) = + let dependencies = + Html.semui @ [ { name = "accordion"; url = "resources/accordion.js"; kind = Script }] + + let attributes = + let basic = + AttributeMap.ofList [ + clazz "ui accordion" + + if toggle.IsSome then + onEvent "onopen" [] (List.head >> int >> toggle.Value true) + onEvent "onclose" [] (List.head >> int >> toggle.Value false) + ] + + AttributeMap.union attributes basic + + let boot = + let exclusive = + match active with + | Choice2Of2 _ -> "true" + | _ -> "false" + + String.concat "" [ + "const $self = $('#__ID__');" + "aardvark.accordion($self, " + exclusive + ", channelActive);" + ] + + let channels = + let channel = + match active with + | Choice1Of2 set -> ASet.channel set + | Choice2Of2 index -> AVal.channel index + + [ "channelActive", channel ] + + require dependencies ( + onBoot' channels boot ( + Incremental.div attributes <| alist { + for (title, node) in sections do + div [clazz "title"] [ + i [clazz "dropdown icon"] [] + text title + ] + div [clazz "content"] [ + node + ] + } + ) + ) + + /// Simple container dividing content into titled sections, which can be opened and closed. + /// The active set holds the indices of the open sections. + /// The toggle (index, isOpen) message is fired when a section is opened or closed. + let accordion (toggle: int * bool -> 'msg) (active: aset) + (attributes: AttributeMap<'msg>) (sections: alist>) = + sections |> accordionImpl (Some (fun s i -> toggle (i, s))) (Choice1Of2 active) attributes + + /// Simple container dividing content into titled sections, which can be opened and closed (only one can be open at a time). + /// The active value holds the index of the open section, or -1 if there is no open section. + /// The setActive (index | -1) message is fired when a section is opened or closed. + let accordionExclusive (setActive: int -> 'msg) (active: aval) + (attributes: AttributeMap<'msg>) (sections: alist>) = + let map o i = if o then i else -1 + sections |> accordionImpl (Some (fun s -> map s >> setActive)) (Choice2Of2 active) attributes + + /// Simple container dividing content into titled sections, which can be opened and closed. + /// If exclusive is true, only one section can be open at a time. + let accordionSimple (exclusive: bool) (attributes: AttributeMap<'msg>) (sections: alist>) = + let active = + if exclusive then Choice2Of2 (AVal.constant -1) + else Choice1Of2 ASet.empty + + sections |> accordionImpl None active attributes + [] module ``Primtive Builders`` = @@ -883,4 +958,26 @@ module SimplePrimitives = let inline dropdownMultiSelect (attributes : AttributeMap<'msg>) (compare : Option<'T -> 'T -> int>) (defaultText : string) (values : amap<'T, DomNode<'msg>>) (selected : alist<'T>) (update : 'T list -> 'msg) = - Incremental.dropdownMultiSelect attributes compare defaultText values selected update \ No newline at end of file + Incremental.dropdownMultiSelect attributes compare defaultText values selected update + + /// Simple container dividing content into titled sections, which can be opened and closed. + /// The active set holds the indices of the open sections. + /// The toggle message (index, isOpen) is fired when a section is opened or closed. + let inline accordion (toggle: int * bool -> 'msg) (active: aset) + (attributes: Attribute<'msg> list) (sections: list>) = + let attributes = AttributeMap.ofList attributes + sections |> AList.ofList |> Incremental.accordion toggle active attributes + + /// Simple container dividing content into titled sections, which can be opened and closed (only one can be open at a time). + /// The active value holds the index of the open section, or -1 if there is no open section. + /// The setActive (index | -1) message is fired when a section is opened or closed. + let inline accordionExclusive (setActive: int -> 'msg) (active: aval) + (attributes: Attribute<'msg> list) (sections: list>) = + let attributes = AttributeMap.ofList attributes + sections |> AList.ofList |> Incremental.accordionExclusive setActive active attributes + + /// Simple container dividing content into titled sections, which can be opened and closed. + /// If exclusive is true, only one section can be open at a time. + let inline accordionSimple (exclusive: bool) (attributes: Attribute<'msg> list) (sections: list>) = + let attributes = AttributeMap.ofList attributes + sections |> AList.ofList |> Incremental.accordionSimple exclusive attributes \ No newline at end of file diff --git a/src/Aardvark.UI.Primitives/resources/accordion.js b/src/Aardvark.UI.Primitives/resources/accordion.js new file mode 100644 index 00000000..782a95cd --- /dev/null +++ b/src/Aardvark.UI.Primitives/resources/accordion.js @@ -0,0 +1,65 @@ +if (!aardvark.accordion) { + /** + * @param {HTMLElement[]} $self + * @param {HTMLElement} item + * @param {string} event + */ + const onToggle = function ($self, item, event) { + const index = $self.children('.content').index(item); + aardvark.processEvent($self[0].id, event, index); + }; + + /** + * @param {HTMLElement[]} $self + * @param {{cnt: int, value: int}} op + */ + const processMessage = function ($self, op) { + if (op.value < 0 || op.value >= $self.children('.content').length) { + return; + } + + if (op.cnt > 0) { + $self.accordion('open', op.value); + } else if (op.cnt < 0) { + $self.accordion('close', op.value); + } + }; + + /** + * @param {HTMLElement[]} $self + * @param {int} index + */ + const processExclusiveMessage = function ($self, index) { + if (index >= 0) { + if (index < $self.children('.content').length) { + $self.accordion('open', index); + } + } else { + const $content = $self.children('.content'); + const active = $content.index($content.filter('.active')); + + if (active >= 0) { + $self.accordion('close', active); + } + } + }; + + /** + * @param {HTMLElement[]} $self + * @param {boolean} exclusive + * @param {{onmessage: function}} channel + */ + aardvark.accordion = function ($self, exclusive, channel) { + $self.accordion({ + exclusive: exclusive, + onOpen: function () { onToggle($self, this, 'onopen'); }, + onClose: function () { onToggle($self, this, 'onclose'); } + }); + + if (exclusive) { + channel.onmessage = (index) => processExclusiveMessage($self, index); + } else { + channel.onmessage = (op) => processMessage($self, op); + } + }; +} \ No newline at end of file diff --git a/src/Examples (dotnetcore)/23 - Inputs/App.fs b/src/Examples (dotnetcore)/23 - Inputs/App.fs index 514c87b8..ff42c62f 100644 --- a/src/Examples (dotnetcore)/23 - Inputs/App.fs +++ b/src/Examples (dotnetcore)/23 - Inputs/App.fs @@ -75,8 +75,7 @@ let view (model : AdaptiveModel) = div [ style "margin-bottom: 10px" ] [ text str ] body [style "background-color: lightslategrey"] [ - div [ clazz "ui vertical inverted menu"; style "min-width: 410px" ] [ - + div [ clazz "ui vertical inverted menu"; style "min-width: 420px" ] [ div [ clazz "item" ] [ button [clazz "ui inverted labeled icon button"; onClick (fun _ -> Reset)] [ i [clazz "red delete icon"] [] @@ -84,177 +83,164 @@ let view (model : AdaptiveModel) = ] ] - // Checkboxes - div [ clazz "header item" ] [ - h3 [] [ text "Checkboxes" ] - ] - - div [ clazz "item" ] [ - simplecheckbox { - attributes [clazz "ui inverted checkbox"] - state model.active - toggle ToggleActive - content [ text "Is the thing active?"; i [clazz "icon rocket" ] [] ] - } - //checkbox [clazz "ui inverted checkbox"] model.active ToggleActive [ text "Is the thing active?"; i [clazz "icon rocket" ] [] ] - ] - - div [ clazz "item" ] [ - checkbox [clazz "ui inverted toggle checkbox"] model.active ToggleActive "Is the thing active?" - ] - - // Sliders - div [ clazz "header item" ] [ - h3 [] [ text "Sliders" ] - ] - - div [ clazz "item" ] [ - description "Float" - slider { min = 1.0; max = 100.0; step = 0.1 } [clazz "ui inverted red slider"] model.value SetValue - ] - - div [ clazz "item" ] [ - description "Integer" - slider { min = 0; max = 20; step = 1 } [clazz "ui inverted small bottom aligned labeled ticked blue slider"] model.intValue SetInt - ] - - // Input fields - div [ clazz "header item" ] [ - h3 [] [ text "Input fields" ] - ] - - div [ clazz "item" ] [ - description "Numeric (float)" - simplenumeric { - attributes [clazz "ui inverted input"] - value model.value - update SetValue - step 0.1 - largeStep 1.0 - min 1.0 - max 100.0 - } - //numeric { min = -1E15; max = 1E15; smallStep = 0.1; largeStep = 100.0 } [clazz "ui inverted input"] model.value SetValue - ] - - div [ clazz "item" ] [ - description "Numeric (integer)" - simplenumeric' { - attributes [clazz "ui inverted input"] - value model.intValue - iconRight "inverted users" - update SetInt - step 1 - largeStep 5 - min -100000 - max 100000 - } - //numeric { min = 0; max = 10000; smallStep = 1; largeStep = 10 } [clazz "ui inverted input"] model.intValue SetInt - ] - - div [ clazz "item" ] [ - description "Numeric (decimal)" - simplenumeric { - attributes [clazz "ui inverted input"] - value model.decValue - update SetDecimal - step 1m - largeStep 5m - min -100000m - max 100000m - } - ] - - div [ clazz "item" ] [ - description "Numeric (unsigned integer)" - simplenumeric' { - attributes [clazz "ui inverted input"] - labelLeft "$" - labelRight "inverted basic" ".00" - value model.uintValue - update SetUInt - step 1u - largeStep 5u - min 0u - max 100000u - } - ] - - div [ clazz "item" ] [ - description "Text input with validation" - textbox { regex = Some "^[a-zA-Z_]+$"; maxLength = Some 6 } [clazz "ui inverted input"] model.name SetName - ] + accordionSimple false [ clazz "inverted item" ] [ + // Checkboxes + "Checkboxes", div [ clazz "menu" ] [ + div [ clazz "item" ] [ + simplecheckbox { + attributes [clazz "ui inverted checkbox"] + state model.active + toggle ToggleActive + content [ text "Is the thing active?"; i [clazz "icon rocket" ] [] ] + } + //checkbox [clazz "ui inverted checkbox"] model.active ToggleActive [ text "Is the thing active?"; i [clazz "icon rocket" ] [] ] + ] - // Dropdowns - div [ clazz "header item" ] [ - h3 [] [ text "Dropdown menus" ] - ] + div [ clazz "item" ] [ + checkbox [clazz "ui inverted toggle checkbox"] model.active ToggleActive "Is the thing active?" + ] + ] - div [ clazz "item" ] [ - description "Non-clearable" - dropdownUnclearable [ clazz "inverted selection" ] enumValues model.enumValue SetEnumValue - ] + // Sliders + "Sliders", div [ clazz "menu" ] [ + div [ clazz "item" ] [ + description "Float" + slider { min = 1.0; max = 100.0; step = 0.1 } [clazz "ui inverted red slider"] model.value SetValue + ] - div [ clazz "item" ] [ - description "Clearable" - dropdown { mode = DropdownMode.Text <| Some "blub"; onTrigger = TriggerDropdown.Hover } [ clazz "inverted selection" ] alternatives model.alt SetAlternative - ] + div [ clazz "item" ] [ + description "Integer" + slider { min = 0; max = 20; step = 1 } [clazz "ui inverted small bottom aligned labeled ticked blue slider"] model.intValue SetInt + ] + ] - div [ clazz "item" ] [ - description "Icon mode" - dropdown { mode = DropdownMode.Icon "sidebar"; onTrigger = TriggerDropdown.Hover } [ clazz "inverted icon top left pointing dropdown circular button" ] alternatives model.alt SetAlternative - ] + // Input fields + "Input fields", div [ clazz "menu" ] [ + div [ clazz "item" ] [ + description "Numeric (float)" + simplenumeric { + attributes [clazz "ui inverted input"] + value model.value + update SetValue + step 0.1 + largeStep 1.0 + min 1.0 + max 100.0 + } + //numeric { min = -1E15; max = 1E15; smallStep = 0.1; largeStep = 100.0 } [clazz "ui inverted input"] model.value SetValue + ] - div [ clazz "item" ] [ - description "Multi select" - let atts = AttributeMap.ofList [clazz "inverted clearable search"] - dropdownMultiSelect atts None "Search..." alternatives model.alts SetAlternatives - ] + div [ clazz "item" ] [ + description "Numeric (integer)" + simplenumeric' { + attributes [clazz "ui inverted input"] + value model.intValue + iconRight "inverted users" + update SetInt + step 1 + largeStep 5 + min -100000 + max 100000 + } + //numeric { min = 0; max = 10000; smallStep = 1; largeStep = 10 } [clazz "ui inverted input"] model.intValue SetInt + ] - // Color picker - div [ clazz "header item"; style "display: flex" ] [ - h3 [] [ text "Color picker" ] + div [ clazz "item" ] [ + description "Numeric (decimal)" + simplenumeric { + attributes [clazz "ui inverted input"] + value model.decValue + update SetDecimal + step 1m + largeStep 5m + min -100000m + max 100000m + } + ] - Incremental.div (AttributeMap.ofAMap <| amap { - let! c = model.color - yield style $"width: 16px; height: 16px; margin-left: 10px; margin-top: 5px; border: thin solid; background-color: #{c.ToHexString()}" - }) AList.empty - ] + div [ clazz "item" ] [ + description "Numeric (unsigned integer)" + simplenumeric' { + attributes [clazz "ui inverted input"] + labelLeft "$" + labelRight "inverted basic" ".00" + value model.uintValue + update SetUInt + step 1u + largeStep 5u + min 0u + max 100000u + } + ] - div [ clazz "item" ] [ - description "Dropdown variations" + div [ clazz "item" ] [ + description "Text input with validation" + textbox { regex = Some "^[a-zA-Z_]+$"; maxLength = Some 6 } [clazz "ui inverted input"] model.name SetName + ] + ] - div [style "display: flex"] [ - div [style "margin-right: 5px"] [ - let cfg = { ColorPicker.Config.Dark.Default with palette = Some ColorPicker.Palette.Basic } - ColorPicker.view cfg SetColor model.color + // Dropdowns + "Dropdown menus", div [ clazz "menu" ] [ + div [ clazz "item" ] [ + description "Non-clearable" + dropdownUnclearable [ clazz "inverted selection" ] enumValues model.enumValue SetEnumValue ] - div [style "margin-left: 5px; margin-right: 5px"] [ - let cfg = { ColorPicker.Config.Dark.PaletteOnly with palette = Some ColorPicker.Palette.Reduced } - ColorPicker.view cfg SetColor model.color + div [ clazz "item" ] [ + description "Clearable" + dropdown { mode = DropdownMode.Text <| Some "blub"; onTrigger = TriggerDropdown.Hover } [ clazz "inverted selection" ] alternatives model.alt SetAlternative ] - div [style "margin-left: 5px; margin-right: 5px"] [ - let cfg = { ColorPicker.Config.Dark.PickerOnlyWithAlpha with preferredFormat = ColorPicker.Format.HSL } - ColorPicker.view cfg SetColor model.color + div [ clazz "item" ] [ + description "Icon mode" + dropdown { mode = DropdownMode.Icon "sidebar"; onTrigger = TriggerDropdown.Hover } [ clazz "inverted icon top left pointing dropdown circular button" ] alternatives model.alt SetAlternative ] - div [style "margin-left: 5px"] [ - ColorPicker.view ColorPicker.Config.Dark.Disabled SetColor model.color + div [ clazz "item" ] [ + description "Multi select" + let atts = AttributeMap.ofList [clazz "inverted clearable search"] + dropdownMultiSelect atts None "Search..." alternatives model.alts SetAlternatives ] ] - ] - div [ clazz "item" ] [ - description "Inline with persistent selection" + // Color picker + "Color picker", div [ clazz "menu" ] [ + div [ clazz "item" ] [ + description "Dropdown variations" + + div [style "display: flex"] [ + div [style "margin-right: 5px"] [ + let cfg = { ColorPicker.Config.Dark.Default with palette = Some ColorPicker.Palette.Basic } + ColorPicker.view cfg SetColor model.color + ] + + div [style "margin-left: 5px; margin-right: 5px"] [ + let cfg = { ColorPicker.Config.Dark.PaletteOnly with palette = Some ColorPicker.Palette.Reduced } + ColorPicker.view cfg SetColor model.color + ] + + div [style "margin-left: 5px; margin-right: 5px"] [ + let cfg = { ColorPicker.Config.Dark.PickerOnlyWithAlpha with preferredFormat = ColorPicker.Format.HSL } + ColorPicker.view cfg SetColor model.color + ] + + div [style "margin-left: 5px"] [ + ColorPicker.view ColorPicker.Config.Dark.Disabled SetColor model.color + ] + ] + ] - let cfg = { ColorPicker.Config.Dark.Default with - localStorageKey = Some "aardvark.media.colorpicker.example" - pickerStyle = Some { ColorPicker.PickerStyle.ToggleWithAlpha with showButtons = true; textInput = ColorPicker.TextInput.Enabled } - displayMode = ColorPicker.DisplayMode.Inline } + div [ clazz "item" ] [ + description "Inline with persistent selection palette" - ColorPicker.view cfg SetColor model.color + let cfg = { ColorPicker.Config.Dark.Default with + localStorageKey = Some "aardvark.media.colorpicker.example" + pickerStyle = Some { ColorPicker.PickerStyle.ToggleWithAlpha with showButtons = true; textInput = ColorPicker.TextInput.Enabled } + displayMode = ColorPicker.DisplayMode.Inline } + + ColorPicker.view cfg SetColor model.color + ] + ] ] ] ]