diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d0e3c6fa..954e7e06 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -11,10 +11,15 @@ jobs: - uses: actions/setup-python@v4 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 # minimum required for dependencies - run: npm install -g @mermaid-js/mermaid-cli + - name: Corepack + run: | + npm install --global corepack@latest + corepack enable + - name: Install Project run: make install diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4bff7b70..7c8f5839 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,18 +10,30 @@ jobs: fail-fast: false matrix: python: - - "3.7" - - "3.8" - "3.9" - "3.10" - "3.11" - - "3.12" + # - "3.12" + # - "3.13" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Install Project - run: make install + - name: Set Up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} - - name: Run Tests - run: make test + - name: Check Python Version + run: python --version + + - name: Corepack + run: | + npm install --global corepack@latest + corepack enable + + - name: Install Project + run: pnpm install + + - name: Run Tests + run: make test diff --git a/.gitignore b/.gitignore index 60171de0..11882162 100644 --- a/.gitignore +++ b/.gitignore @@ -7,20 +7,22 @@ /.pydevproject.bak /.settings/ /.vscode/ +/build/ /constraints-mxdev.txt /coverage/ /dist/ -/docs/Makefile /docs/cone.app.zip /docs/doctrees/ /docs/html/ /docs/latex/ /docs/make.bat +/docs/Makefile /htmlcov/ /js/karma/ /node_modules/ /package-lock.json /pip-selfcheck.json +/pnpm-lock.yaml /pyvenv.cfg /requirements-mxdev.txt /sources/ diff --git a/CHANGES.rst b/CHANGES.rst index d6e30b4f..8fcf84e0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,24 @@ Changes ======= +2.0a1 (unreleased) +------------------ + +- Cleanup js widgets to prevent DOM memory leaks. + [lenadax] + +- Remove no longer used ``content_grid_width`` and ``sidebar_left_grid_width`` + properties from ``ILayoutConfig``. + [rnix] + +- Run tests on bootstrap5 factory theme. + [lenadax] + + 1.1rc2 (unreleased) ------------------- -- Nothing changed yet. +- No changes yet. 1.1rc1 (2024-05-23) diff --git a/Makefile b/Makefile index ffa2156f..9214118b 100644 --- a/Makefile +++ b/Makefile @@ -10,11 +10,12 @@ #: docs.sphinx #: i18n.gettext #: i18n.lingua -#: js.karma -#: js.npm +#: js.nodejs #: js.rollup #: js.scss +#: js.wtr #: qa.coverage +#: qa.ruff #: qa.test # # SETTINGS (ALL CHANGES MADE BELOW SETTINGS WILL BE LOST) @@ -32,7 +33,7 @@ RUN_TARGET?= # Additional files and folders to remove when running clean target # No default value. -CLEAN_FS?= +CLEAN_FS?=pnpm-lock.yaml # Optional makefile to include before default targets. This can # be used to provide custom targets or hook up to existing targets. @@ -45,32 +46,47 @@ INCLUDE_MAKEFILE?=include.mk # No default value. EXTRA_PATH?= -## js.npm +## js.nodejs -# Value for `--prefix` option. +# The package manager to use. Defaults to `npm`. Possible values +# are `npm` and `pnpm` +# Default: npm +NODEJS_PACKAGE_MANAGER?=pnpm + +# Value for `--prefix` option when installing packages. # Default: . -NPM_PREFIX?=. +NODEJS_PREFIX?=. -# Packages which get installed with `--no-save` option. +# Packages to install with `--no-save` option. # No default value. -NPM_PACKAGES?= +NODEJS_PACKAGES?= -# Packages which get installed with `--save-dev` option. +# Packages to install with `--save-dev` option. # No default value. -NPM_DEV_PACKAGES?= +NODEJS_DEV_PACKAGES?= -# Packages which get installed with `--save-prod` option. +# Packages to install with `--save-prod` option. # No default value. -NPM_PROD_PACKAGES?= +NODEJS_PROD_PACKAGES?= -# Packages which get installed with `--save-optional` option. +# Packages to install with `--save-optional` option. # No default value. -NPM_OPT_PACKAGES?= +NODEJS_OPT_PACKAGES?= # Additional install options. Possible values are `--save-exact` # and `--save-bundle`. # No default value. -NPM_INSTALL_OPTS?= +NODEJS_INSTALL_OPTS?= + +## js.wtr + +# Web test runner config file. +# Default: wtr.config.mjs +WTR_CONFIG?=js/wtr.config.mjs + +# Web test runner additional command line options. +# Default: --coverage +WTR_OPTIONS?=--coverage ## js.scss @@ -96,16 +112,6 @@ SCSS_OPTIONS?=--no-source-map=none # Default: rollup.conf.js ROLLUP_CONFIG?=js/rollup.conf.js -## js.karma - -# Karma config file. -# Default: karma.conf.js -KARMA_CONFIG?=js/karma.conf.js - -# Karma additional command line options. -# Default: --single-run -KARMA_OPTIONS?=--single-run - ## core.mxenv # Primary Python interpreter to use. It is used to create the @@ -114,8 +120,8 @@ KARMA_OPTIONS?=--single-run PRIMARY_PYTHON?=python3 # Minimum required Python version. -# Default: 3.7 -PYTHON_MIN_VERSION?=3.7 +# Default: 3.9 +PYTHON_MIN_VERSION?=3.9 # Install packages using the given package installer method. # Supported are `pip` and `uv`. If uv is used, its global availability is @@ -158,6 +164,12 @@ MXDEV?=mxdev # Default: mxmake MXMAKE?=mxmake +## qa.ruff + +# Source folder to scan for Python files to run ruff on. +# Default: src +RUFF_SRC?=src + ## docs.sphinx # Documentation source folder. @@ -232,6 +244,10 @@ LINGUA_SEARCH_PATH?=src/cone/app # No default value. LINGUA_PLUGINS?= +# Command line options passed to `pot-create` +# No default value. +LINGUA_OPTIONS?= + ############################################################################## # END SETTINGS - DO NOT EDIT BELOW THIS LINE ############################################################################## @@ -267,66 +283,75 @@ $(SENTINEL): $(firstword $(MAKEFILE_LIST)) @echo "Sentinels for the Makefile process." > $(SENTINEL) ############################################################################## -# npm +# nodejs ############################################################################## -export PATH:=$(shell pwd)/$(NPM_PREFIX)/node_modules/.bin:$(PATH) +export PATH:=$(shell pwd)/$(NODEJS_PREFIX)/node_modules/.bin:$(PATH) -# case `system.dependencies` domain is included -SYSTEM_DEPENDENCIES+=npm -NPM_TARGET:=$(SENTINEL_FOLDER)/npm.sentinel -$(NPM_TARGET): $(SENTINEL) - @echo "Install npm packages" - @test -z "$(NPM_DEV_PACKAGES)" \ +NODEJS_TARGET:=$(SENTINEL_FOLDER)/nodejs.sentinel +$(NODEJS_TARGET): $(SENTINEL) + @echo "Install nodejs packages" + @test -z "$(NODEJS_DEV_PACKAGES)" \ && echo "No dev packages to be installed" \ - || npm --prefix $(NPM_PREFIX) install \ + || $(NODEJS_PACKAGE_MANAGER) --prefix $(NODEJS_PREFIX) install \ --save-dev \ - $(NPM_INSTALL_OPTS) \ - $(NPM_DEV_PACKAGES) - @test -z "$(NPM_PROD_PACKAGES)" \ + $(NODEJS_INSTALL_OPTS) \ + $(NODEJS_DEV_PACKAGES) + @test -z "$(NODEJS_PROD_PACKAGES)" \ && echo "No prod packages to be installed" \ - || npm --prefix $(NPM_PREFIX) install \ + || $(NODEJS_PACKAGE_MANAGER) --prefix $(NODEJS_PREFIX) install \ --save-prod \ - $(NPM_INSTALL_OPTS) \ - $(NPM_PROD_PACKAGES) - @test -z "$(NPM_OPT_PACKAGES)" \ + $(NODEJS_INSTALL_OPTS) \ + $(NODEJS_PROD_PACKAGES) + @test -z "$(NODEJS_OPT_PACKAGES)" \ && echo "No opt packages to be installed" \ - || npm --prefix $(NPM_PREFIX) install \ + || $(NODEJS_PACKAGE_MANAGER) --prefix $(NODEJS_PREFIX) install \ --save-optional \ - $(NPM_INSTALL_OPTS) \ - $(NPM_OPT_PACKAGES) - @test -z "$(NPM_PACKAGES)" \ + $(NODEJS_INSTALL_OPTS) \ + $(NODEJS_OPT_PACKAGES) + @test -z "$(NODEJS_PACKAGES)" \ && echo "No packages to be installed" \ - || npm --prefix $(NPM_PREFIX) install \ + || $(NODEJS_PACKAGE_MANAGER) --prefix $(NODEJS_PREFIX) install \ --no-save \ - $(NPM_PACKAGES) - @touch $(NPM_TARGET) + $(NODEJS_PACKAGES) + @touch $(NODEJS_TARGET) + +.PHONY: nodejs +nodejs: $(NODEJS_TARGET) -.PHONY: npm -npm: $(NPM_TARGET) +.PHONY: nodejs-dirty +nodejs-dirty: + @rm -f $(NODEJS_TARGET) -.PHONY: npm-dirty -npm-dirty: - @rm -f $(NPM_TARGET) +.PHONY: nodejs-clean +nodejs-clean: nodejs-dirty + @rm -rf $(NODEJS_PREFIX)/node_modules + +INSTALL_TARGETS+=nodejs +DIRTY_TARGETS+=nodejs-dirty +CLEAN_TARGETS+=nodejs-clean + +############################################################################## +# web test runner +############################################################################## -.PHONY: npm-clean -npm-clean: npm-dirty - @rm -rf $(NPM_PREFIX)/node_modules +NODEJS_DEV_PACKAGES+=\ + @web/test-runner \ + @web/dev-server-import-maps -INSTALL_TARGETS+=npm -DIRTY_TARGETS+=npm-dirty -CLEAN_TARGETS+=npm-clean +.PHONY: wtr +wtr: $(NODEJS_TARGET) + @web-test-runner $(WTR_OPTIONS) --config $(WTR_CONFIG) ############################################################################## # scss ############################################################################## -# extend npm dev packages -NPM_DEV_PACKAGES+=sass +NODEJS_DEV_PACKAGES+=sass .PHONY: scss -scss: $(NPM_TARGET) +scss: $(NODEJS_TARGET) @sass $(SCSS_OPTIONS) $(SCSS_SOURCE) $(SCSS_TARGET) @sass $(SCSS_OPTIONS) --style compressed $(SCSS_SOURCE) $(SCSS_MIN_TARGET) @@ -334,35 +359,21 @@ scss: $(NPM_TARGET) # rollup ############################################################################## -# extend npm dev packages -NPM_DEV_PACKAGES+=\ +NODEJS_DEV_PACKAGES+=\ rollup \ rollup-plugin-cleanup \ @rollup/plugin-terser .PHONY: rollup -rollup: $(NPM_TARGET) +rollup: $(NODEJS_TARGET) @rollup --config $(ROLLUP_CONFIG) -############################################################################## -# karma -############################################################################## - -# extend npm dev packages -NPM_DEV_PACKAGES+=\ - karma \ - karma-coverage \ - karma-chrome-launcher \ - karma-module-resolver-preprocessor - -.PHONY: karma -karma: $(NPM_TARGET) - @karma start $(KARMA_CONFIG) $(KARMA_OPTIONS) - ############################################################################## # mxenv ############################################################################## +export OS:=$(OS) + # Determine the executable path ifeq ("$(VENV_ENABLED)", "true") export VIRTUAL_ENV=$(abspath $(VENV_FOLDER)) @@ -437,6 +448,41 @@ INSTALL_TARGETS+=mxenv DIRTY_TARGETS+=mxenv-dirty CLEAN_TARGETS+=mxenv-clean +############################################################################## +# ruff +############################################################################## + +RUFF_TARGET:=$(SENTINEL_FOLDER)/ruff.sentinel +$(RUFF_TARGET): $(MXENV_TARGET) + @echo "Install Ruff" + @$(PYTHON_PACKAGE_COMMAND) install ruff + @touch $(RUFF_TARGET) + +.PHONY: ruff-check +ruff-check: $(RUFF_TARGET) + @echo "Run ruff check" + @ruff check $(RUFF_SRC) + +.PHONY: ruff-format +ruff-format: $(RUFF_TARGET) + @echo "Run ruff format" + @ruff format $(RUFF_SRC) + +.PHONY: ruff-dirty +ruff-dirty: + @rm -f $(RUFF_TARGET) + +.PHONY: ruff-clean +ruff-clean: ruff-dirty + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y ruff || : + @rm -rf .ruff_cache + +INSTALL_TARGETS+=$(RUFF_TARGET) +CHECK_TARGETS+=ruff-check +FORMAT_TARGETS+=ruff-format +DIRTY_TARGETS+=ruff-dirty +CLEAN_TARGETS+=ruff-clean + ############################################################################## # sphinx ############################################################################## @@ -717,7 +763,7 @@ PHONY: lingua-extract lingua-extract: $(LINGUA_TARGET) @echo "Extract messages" @pot-create \ - "$(LINGUA_SEARCH_PATH)" \ + "$(LINGUA_SEARCH_PATH)" $(LINGUA_OPTIONS) \ -o "$(GETTEXT_LOCALES_PATH)/$(GETTEXT_DOMAIN).pot" PHONY: lingua @@ -736,6 +782,10 @@ INSTALL_TARGETS+=$(LINGUA_TARGET) DIRTY_TARGETS+=lingua-dirty CLEAN_TARGETS+=lingua-clean +############################################################################## +# Custom includes +############################################################################## + -include $(INCLUDE_MAKEFILE) ############################################################################## diff --git a/README.rst b/README.rst index 0242cd61..ca1d4d3b 100644 --- a/README.rst +++ b/README.rst @@ -29,6 +29,14 @@ The sources are in a GIT DVCS with its main branches at `github `_. +Third Party Resources +===================== + +- https://github.com/jquery/jquery +- https://github.com/twbs/bootstrap +- https://github.com/HatScripts/circle-flags + + Copyright ========= diff --git a/TODO.rst b/TODO.rst index ed176826..e1804c75 100644 --- a/TODO.rst +++ b/TODO.rst @@ -1,65 +1,15 @@ -==== TODO ==== -Docs -==== - -- Document expected permissions for tiles and actions -- Create writing tests documenatation -- Create twisted integration documentation -- Create websocket integration documentation -- Create tutorial extending ``cone.example`` -- Proper cross linking all over the place -- Add proper API docs to code and include in docs. -- Update javascript development docs (treibstoff/rollup) - -Upcoming -======== - -- Treibstoff migration docs. -- Treibtroff AjaxOverlay docs. -- Context bound toolbar and action (named utilities?). -- Layout property for excluding entire navbar. -- Reference blueprint -> use lookup function also for single value references - if present. -- Consolidate "Unauthorized" and "Insufficient Privileges" tiles. -- Check ``title`` of transition names before using ``name`` in workflow - dropdown. -- Bind sharing view to ``cone.app.interfaces.IPrincipalACL``. -- Fix ACL registry lookup. First check by cls and node info name, then by - class only and finally return default. -- CopySupport is used both for marking containers supporting cut/copy/paste - and objects being copyable. Make dedicated interface/mechanism for marking - objects copyable. -- Add template for creating ``cone.app`` plugins. -- Overhaul plugin entry hooks staying closer to pyramid if possible. -- Pyramid request wrapper for autobahn websocket requests to enable proper - security integration. -- ``cone.tile.Tile`` should point to template at ``template`` instead of - ``path``. -- Use ``BatchedItems`` as base for ``Table``. -- Rename ``cone.app.browser.batch.Batch`` to - ``cone.app.browser.batch.Pagination`` providing B/C. -- Provide a ``form_action`` property on ``cone.app.browser.form.Form`` - considering ``action_resource`` attribute. Consolidate with - ``cone.app.browser.Form.YAMLForm.form_action``. -- Test ``cone.app.browser.actions.DropdownAction`` with BS3. -- ``cone.app.browser.copysupport#124``: trigger ``contextchanged`` on - ``#layout`` instead of ``.contextsensitiv``. -- Get rid of remaining ``contextsensitiv`` CSS class related bdajax - bindings and remove ``contextsensitiv`` CSS class entirly from markup and - tests. -- Consolidate ``cone.app.model.AppSettings.__acl__```and - ``cone.app.security.DEFAULT_SETTINGS_ACL`` which is not used yet in - ``cone.app``. -- Fix lookup in ACL registry. First node by class or base class and node - info name if given, Then by class or base class only if not found, then - by node info name only if no class given at lookup. Or so... -- Create and use constants for all default roles and permissions. -- Adopt livesearch JS intergration to provide hooks for passing typeahead - options and datasets instead of just datasets. -- Sharing tile table sorting by principal title instead of principal id -- Bind navtree to ``list`` permission? -- Update jQuery. -- Update bootstrap. +- Upgrade pyramid to 2.0 for python 3.12 and higher. This is necessary because pyramid 1.9.4 + depends on the imp module, which has been removed since python 3.12. +- Main menu display children. +- Custom root node in example. +- Referencebrowser widget. +- Treibstoff styles. +- Yafowil bootstrap 5 styles. +- Yafowil addon widgets. +- SCSS splitting. +- Reduce CSS class noise and add some custom rules to SCSS files. +- Check if we can retrieve Bootstrap sources other than including in this repo + for development. diff --git a/examples/cone.example/.gitignore b/examples/cone.example/.gitignore deleted file mode 100644 index 9c175fe0..00000000 --- a/examples/cone.example/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -/pyvenv.cfg -/bin/ -/develop-eggs/ -/eggs/ -/include/ -/lib/ -/local/ -/share/ -/lib64 -/pip-selfcheck.json -/src/cone.example.egg-info/ - diff --git a/examples/cone.example/bootstrap.sh b/examples/cone.example/bootstrap.sh deleted file mode 100755 index 6deab9fc..00000000 --- a/examples/cone.example/bootstrap.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -rm -r ./lib ./include ./local ./bin ./share -python3 -m venv . -./bin/pip install pyramid==1.9.4 -./bin/pip install repoze.zcml==1.0b1 -./bin/pip install repoze.workflow==1.0b1 -./bin/pip install -e . diff --git a/examples/cone.example/example.ini b/examples/cone.example/example.ini index 3d5018e5..f92ef530 100644 --- a/examples/cone.example/example.ini +++ b/examples/cone.example/example.ini @@ -20,9 +20,12 @@ pyramid.debug_templates = true # default language pyramid.default_locale_name = en +# available languages +cone.available_languages = de, en + # cone.app admin user and password -cone.admin_user = admin -cone.admin_password = admin +#cone.admin_user = +#cone.admin_password = # cone.app auth tkt settings cone.auth_secret = 12345 @@ -41,10 +44,16 @@ cone.plugins = cone.example # application root node settings cone.root.title = cone.example -cone.root.default_child = example +#cone.root.default_child = example #cone.root.default_content_tile = #cone.root.mainmenu_empty_title = false +ugm.backend = file +ugm.users_file = var/ugm/users +ugm.groups_file = var/ugm/groups +ugm.roles_file = var/ugm/roles +ugm.datadir = var/ugm/data + [pipeline:main] pipeline = example diff --git a/examples/cone.example/i18n.sh b/examples/cone.example/i18n.sh deleted file mode 100755 index acb520d7..00000000 --- a/examples/cone.example/i18n.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# Usage: -# Initial catalog creation (lang is the language identifier): -# ./i18n.sh lang -# Updating translation and compile catalog: -# ./i18n.sh - -# configuration -DOMAIN="cone.example" -SEARCH_PATH="src/cone/example" -LOCALES_PATH="src/cone/example/locale" -# end configuration - -# create locales folder if not exists -if [ ! -d "$LOCALES_PATH" ]; then - echo "Locales directory not exists, create" - mkdir -p "$LOCALES_PATH" -fi - -# create pot if not exists -if [ ! -f "$LOCALES_PATH"/$DOMAIN.pot ]; then - echo "Create pot file" - touch "$LOCALES_PATH"/$DOMAIN.pot -fi - -# no arguments, extract and update -if [ $# -eq 0 ]; then - echo "Extract messages" - pot-create "$SEARCH_PATH" -o "$LOCALES_PATH"/$DOMAIN.pot - - echo "Update translations" - for po in "$LOCALES_PATH"/*/LC_MESSAGES/$DOMAIN.po; do - msgmerge -o "$po" "$po" "$LOCALES_PATH"/$DOMAIN.pot - done - - echo "Compile message catalogs" - for po in "$LOCALES_PATH"/*/LC_MESSAGES/*.po; do - msgfmt --statistics -o "${po%.*}.mo" "$po" - done - -# first argument represents language identifier, create catalog -else - cd "$LOCALES_PATH" - mkdir -p $1/LC_MESSAGES - msginit -i $DOMAIN.pot -o $1/LC_MESSAGES/$DOMAIN.po -l $1 -fi diff --git a/examples/cone.example/lingua.cfg b/examples/cone.example/lingua.cfg new file mode 100644 index 00000000..697625bf --- /dev/null +++ b/examples/cone.example/lingua.cfg @@ -0,0 +1,2 @@ +[extensions] +.zcml = xml \ No newline at end of file diff --git a/examples/cone.example/setup.py b/examples/cone.example/setup.py index 53cfe0c7..4f76c5a2 100644 --- a/examples/cone.example/setup.py +++ b/examples/cone.example/setup.py @@ -16,7 +16,7 @@ include_package_data=True, zip_safe=False, install_requires=[ - 'waitress', - 'cone.app' + 'cone.app', + 'waitress' ] ) diff --git a/examples/cone.example/src/cone/example/__init__.py b/examples/cone.example/src/cone/example/__init__.py index 8fcee891..f0764a72 100644 --- a/examples/cone.example/src/cone/example/__init__.py +++ b/examples/cone.example/src/cone/example/__init__.py @@ -1,24 +1,27 @@ from cone.app import main_hook from cone.app import register_entry -from cone.example.browser import static_resources -from cone.example.model import ExamplePlugin -import cone.app +from cone.example.browser import configure_resources +from cone.example.model import EntryFolder +from cone.example.model import LiveSearch @main_hook -def example_main_hook(config, global_config, local_config): +def example_main_hook(config, global_config, settings): """Function which gets called at application startup to initialize this plugin. """ - # register static resources view - config.add_view(static_resources, name='example-static') + # register live search adapter + config.registry.registerAdapter(LiveSearch) - # register static resources to be delivered - cone.app.cfg.css.public.append('example-static/example.css') - cone.app.cfg.js.public.append('example-static/example.js') + # add translation + config.add_translation_dirs('cone.example:locale/') - # register plugin entry node - register_entry('example', ExamplePlugin) + # register plugin entry nodes + for i in range(1, 6): + register_entry(f'folder_{i}', EntryFolder) + + # static resources + configure_resources(config, settings) # scan browser package config.scan('cone.example.browser') diff --git a/examples/cone.example/src/cone/example/browser/__init__.py b/examples/cone.example/src/cone/example/browser/__init__.py index e51fab32..7b39a1d2 100644 --- a/examples/cone.example/src/cone/example/browser/__init__.py +++ b/examples/cone.example/src/cone/example/browser/__init__.py @@ -1,15 +1,145 @@ +from cone.app.browser.authoring import ContentAddForm +from cone.app.browser.authoring import ContentEditForm +from cone.app.browser.form import AddFormTarget +from cone.app.browser.form import EditFormTarget +from cone.app.browser.form import Form from cone.app.browser.layout import ProtectedContentTile -from cone.example.model import ExamplePlugin +from cone.app.browser.utils import choose_name +from cone.app.utils import add_creation_metadata +from cone.app.utils import update_creation_metadata +from cone.example.model import EntryFolder +from cone.example.model import Folder +from cone.example.model import Item +from cone.example.model import Translation from cone.tile import tile -from pyramid.static import static_view +from cone.tile import tile +from node.utils import UNSET +from plumber import plumbing +from pyramid.i18n import TranslationStringFactory +from yafowil.base import factory +from yafowil.persistence import write_mapping_writer +import os +import webresource as wr + + +_ = TranslationStringFactory('cone.example') -static_resources = static_view('static', use_subpath=True) +resources_dir = os.path.join(os.path.dirname(__file__), 'static') +cone_example_resources = wr.ResourceGroup( + name='cone.example', + directory=resources_dir, + path='example' +) +cone_example_resources.add(wr.StyleResource( + name='cone-example-css', + resource='cone.example.css' +)) +def configure_resources(config, settings): + config.register_resource(cone_example_resources) + config.set_resource_include('cone-example-css', 'authenticated') + + +@tile(name='view', + path='templates/view.pt', + interface=EntryFolder, + permission='login') +@tile(name='view', + path='templates/view.pt', + interface=Folder, + permission='login') @tile(name='content', - path='templates/example.pt', - interface=ExamplePlugin, + path='templates/view.pt', + interface=Item, permission='login') -class ExamplePluginContent(ProtectedContentTile): +class ViewContent(ProtectedContentTile): pass + + +class ExampleForm(Form): + + def prepare(self): + self.form = form = factory( + 'form', + name='contentform', + props={ + 'action': self.form_action, + 'persist_writer': write_mapping_writer + }) + form['title'] = factory( + 'field:label:help:error:translation:text', + value=self.model.attrs.get('title', UNSET), + props={ + 'factory': Translation, + 'label': _('title', default='Title'), + 'help': _('title_description', default='Enter a title'), + 'required': _('title_required', default='Title is mandatory') + }) + form['description'] = factory( + 'field:label:help:error:translation:textarea', + value=self.model.attrs.get('description', UNSET), + props={ + 'factory': Translation, + 'label': _('description', default='Description'), + 'help': _( + 'description_description', + default='Enter a description' + ), + 'rows': 4 + }) + form['save'] = factory( + 'submit', + props={ + 'action': 'save', + 'expression': True, + 'handler': self.save, + 'next': self.next, + 'label': _('save', default='Save') + }) + form['cancel'] = factory( + 'submit', + props={ + 'action': 'cancel', + 'expression': True, + 'skip': True, + 'next': self.next, + 'label': _('cancel', default='Cancel') + }) + + def save(self, widget, data): + data.write(self.model.attrs) + + +@plumbing(AddFormTarget) +class ExampleAddForm(ExampleForm): + + def save(self, widget, data): + add_creation_metadata(self.request, self.model.attrs) + super(ExampleAddForm, self).save(widget, data) + parent = self.model.parent + parent[choose_name(parent, self.model.metadata.title)] = self.model + + +@plumbing(EditFormTarget) +class ExampleEditForm(ExampleForm): + + def save(self, widget, data): + update_creation_metadata(self.request, self.model.attrs) + super(ExampleEditForm, self).save(widget, data) + + +@tile(name='addform', interface=Folder, permission='add') +@tile(name='addform', interface=Item, permission='add') +@plumbing(ContentAddForm) +class ExampleContentAddForm(ExampleAddForm): + ... + + +@tile(name='editform', interface=EntryFolder, permission='edit') +@tile(name='editform', interface=Folder, permission='edit') +@tile(name='editform', interface=Item, permission='edit') +@plumbing(ContentEditForm) +class ExampleContentEditForm(ExampleEditForm): + ... diff --git a/examples/cone.example/src/cone/example/browser/static/cone.example.css b/examples/cone.example/src/cone/example/browser/static/cone.example.css new file mode 100644 index 00000000..a53f4db8 --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/static/cone.example.css @@ -0,0 +1,8 @@ +#contextmenu a.state-private, +tr.state-private td.title a { + color: red; +} +#contextmenu a.state-public, +tr.state-public td.title a { + color: green; +} diff --git a/examples/cone.example/src/cone/example/browser/static/example.js b/examples/cone.example/src/cone/example/browser/static/example.js deleted file mode 100644 index ed31f0f9..00000000 --- a/examples/cone.example/src/cone/example/browser/static/example.js +++ /dev/null @@ -1,24 +0,0 @@ -(function($) { - - $(document).ready(function() { - // register binder function to bdajax. - $.extend(bdajax.binders, { - example_binder: example.binder - }); - - // call binder function on initial page load. - example.binder(); - }); - - // plugin namespace - example = { - - // plugin binder function. gets called on initial page load and - // every time bdajax modifies the DOM tree. - binder: function(context) { - // event binding code goes here. context is the modified - // part of the DOM tree if called by bdajax. - } - }; - -})(jQuery); diff --git a/examples/cone.example/src/cone/example/browser/templates/example.pt b/examples/cone.example/src/cone/example/browser/templates/example.pt deleted file mode 100644 index 02d53390..00000000 --- a/examples/cone.example/src/cone/example/browser/templates/example.pt +++ /dev/null @@ -1,3 +0,0 @@ -
- Example app content. -
diff --git a/examples/cone.example/src/cone/example/browser/templates/view.pt b/examples/cone.example/src/cone/example/browser/templates/view.pt new file mode 100644 index 00000000..83cb1e2c --- /dev/null +++ b/examples/cone.example/src/cone/example/browser/templates/view.pt @@ -0,0 +1,27 @@ + + + + +
+ +
+ +

+ + Title + +

+ + + +

+ Description +

+ +
+ +
+ +
diff --git a/examples/cone.example/src/cone/example/configure.zcml b/examples/cone.example/src/cone/example/configure.zcml index 5ab97640..bad07151 100644 --- a/examples/cone.example/src/cone/example/configure.zcml +++ b/examples/cone.example/src/cone/example/configure.zcml @@ -1,4 +1,6 @@ - + + + diff --git a/examples/cone.example/src/cone/example/locale/cone.example.pot b/examples/cone.example/src/cone/example/locale/cone.example.pot new file mode 100644 index 00000000..d76cd702 --- /dev/null +++ b/examples/cone.example/src/cone/example/locale/cone.example.pot @@ -0,0 +1,87 @@ +# +# SOME DESCRIPTIVE TITLE +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , 2024. +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE 1.0\n" +"POT-Creation-Date: 2024-06-02 08:52+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Lingua 4.15.0\n" + +#. Default: Folder +#: ./examples/cone.example/src/cone/example/model.py:118 +#: ./examples/cone.example/src/cone/example/model.py:136 +msgid "folder" +msgstr "" + +#. Default: Item +#: ./examples/cone.example/src/cone/example/model.py:150 +msgid "item" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:16 +msgid "private" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:21 +msgid "Object is private" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:25 +msgid "public" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:30 +msgid "Object is public" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:35 +msgid "private_to_public" +msgstr "" + +#: ./examples/cone.example/src/cone/example/publication.zcml:45 +msgid "public_to_private" +msgstr "" + +#. Default: Title +#: ./examples/cone.example/src/cone/example/browser/__init__.py:76 +msgid "title" +msgstr "" + +#. Default: Enter a title +#: ./examples/cone.example/src/cone/example/browser/__init__.py:77 +msgid "title_description" +msgstr "" + +#. Default: Title is mandatory +#: ./examples/cone.example/src/cone/example/browser/__init__.py:78 +msgid "title_required" +msgstr "" + +#. Default: Description +#: ./examples/cone.example/src/cone/example/browser/__init__.py:85 +msgid "description" +msgstr "" + +#. Default: Enter a description +#: ./examples/cone.example/src/cone/example/browser/__init__.py:86 +msgid "description_description" +msgstr "" + +#. Default: Save +#: ./examples/cone.example/src/cone/example/browser/__init__.py:99 +msgid "save" +msgstr "" + +#. Default: Cancel +#: ./examples/cone.example/src/cone/example/browser/__init__.py:108 +msgid "cancel" +msgstr "" diff --git a/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.mo b/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.mo new file mode 100644 index 00000000..0ca9688e Binary files /dev/null and b/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.mo differ diff --git a/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.po b/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.po new file mode 100644 index 00000000..860beb77 --- /dev/null +++ b/examples/cone.example/src/cone/example/locale/de/LC_MESSAGES/cone.example.po @@ -0,0 +1,86 @@ +# +# SOME DESCRIPTIVE TITLE +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , 2024. +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE 1.0\n" +"POT-Creation-Date: 2024-06-02 08:52+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Lingua 4.15.0\n" + +#. Default: Folder +#: examples/cone.example/src/cone/example/model.py:118 +#: examples/cone.example/src/cone/example/model.py:136 +msgid "folder" +msgstr "Ordner" + +#. Default: Item +#: examples/cone.example/src/cone/example/model.py:150 +msgid "item" +msgstr "Object" + +#: examples/cone.example/src/cone/example/publication.zcml:16 +msgid "private" +msgstr "Privat" + +#: examples/cone.example/src/cone/example/publication.zcml:21 +msgid "Object is private" +msgstr "Object ist privat" + +#: examples/cone.example/src/cone/example/publication.zcml:25 +msgid "public" +msgstr "Öffentlich" + +#: examples/cone.example/src/cone/example/publication.zcml:30 +msgid "Object is public" +msgstr "Objekt ist öffentlich" + +#: examples/cone.example/src/cone/example/publication.zcml:35 +msgid "private_to_public" +msgstr "veröffentlichen" + +#: examples/cone.example/src/cone/example/publication.zcml:45 +msgid "public_to_private" +msgstr "zurückziehen" + +#. Default: Title +#: examples/cone.example/src/cone/example/browser/__init__.py:76 +msgid "title" +msgstr "Titel" + +#. Default: Enter a title +#: examples/cone.example/src/cone/example/browser/__init__.py:77 +msgid "title_description" +msgstr "Geben Sie einen Titel ein" + +#. Default: Title is mandatory +#: examples/cone.example/src/cone/example/browser/__init__.py:78 +msgid "title_required" +msgstr "Titel ist erforderlich" + +#. Default: Description +#: examples/cone.example/src/cone/example/browser/__init__.py:85 +msgid "description" +msgstr "Beschreibung" + +#. Default: Enter a description +#: examples/cone.example/src/cone/example/browser/__init__.py:86 +msgid "description_description" +msgstr "Geben Sie eine Beschreibung ein" + +#. Default: Save +#: examples/cone.example/src/cone/example/browser/__init__.py:99 +msgid "save" +msgstr "Speichern" + +#. Default: Cancel +#: examples/cone.example/src/cone/example/browser/__init__.py:108 +msgid "cancel" +msgstr "Abbrechen" diff --git a/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.mo b/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.mo new file mode 100644 index 00000000..29cdd3bb Binary files /dev/null and b/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.mo differ diff --git a/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.po b/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.po new file mode 100644 index 00000000..0dbc1c10 --- /dev/null +++ b/examples/cone.example/src/cone/example/locale/en/LC_MESSAGES/cone.example.po @@ -0,0 +1,86 @@ +# +# SOME DESCRIPTIVE TITLE +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , 2024. +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE 1.0\n" +"POT-Creation-Date: 2024-06-02 08:52+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Lingua 4.15.0\n" + +#. Default: Folder +#: examples/cone.example/src/cone/example/model.py:118 +#: examples/cone.example/src/cone/example/model.py:136 +msgid "folder" +msgstr "Folder" + +#. Default: Item +#: examples/cone.example/src/cone/example/model.py:150 +msgid "item" +msgstr "Item" + +#: examples/cone.example/src/cone/example/publication.zcml:16 +msgid "private" +msgstr "Private" + +#: examples/cone.example/src/cone/example/publication.zcml:21 +msgid "Object is private" +msgstr "Object is private" + +#: examples/cone.example/src/cone/example/publication.zcml:25 +msgid "public" +msgstr "Public" + +#: examples/cone.example/src/cone/example/publication.zcml:30 +msgid "Object is public" +msgstr "Object is public" + +#: examples/cone.example/src/cone/example/publication.zcml:35 +msgid "private_to_public" +msgstr "publish" + +#: examples/cone.example/src/cone/example/publication.zcml:45 +msgid "public_to_private" +msgstr "retract" + +#. Default: Title +#: examples/cone.example/src/cone/example/browser/__init__.py:76 +msgid "title" +msgstr "Title" + +#. Default: Enter a title +#: examples/cone.example/src/cone/example/browser/__init__.py:77 +msgid "title_description" +msgstr "Enter a title" + +#. Default: Title is mandatory +#: examples/cone.example/src/cone/example/browser/__init__.py:78 +msgid "title_required" +msgstr "Title is mandatory" + +#. Default: Description +#: examples/cone.example/src/cone/example/browser/__init__.py:85 +msgid "description" +msgstr "Description" + +#. Default: Enter a description +#: examples/cone.example/src/cone/example/browser/__init__.py:86 +msgid "description_description" +msgstr "Enter a description" + +#. Default: Save +#: examples/cone.example/src/cone/example/browser/__init__.py:99 +msgid "save" +msgstr "Save" + +#. Default: Cancel +#: examples/cone.example/src/cone/example/browser/__init__.py:108 +msgid "cancel" +msgstr "Cancel" diff --git a/examples/cone.example/src/cone/example/model.py b/examples/cone.example/src/cone/example/model.py index 7330137a..2d989507 100644 --- a/examples/cone.example/src/cone/example/model.py +++ b/examples/cone.example/src/cone/example/model.py @@ -1,5 +1,244 @@ -from cone.app.model import BaseNode +from cone.app.browser.utils import make_url +from cone.app.interfaces import IApplicationNode +from cone.app.interfaces import ILiveSearch +from cone.app.interfaces import INavigationLeaf +from cone.app.model import AppNode +from cone.app.model import CopySupport +from cone.app.model import Metadata +from cone.app.model import node_info +from cone.app.model import Properties +from cone.app.model import Translation as TranslationBehavior +from cone.app.security import PrincipalACL +from cone.app.utils import add_creation_metadata +from cone.app.workflow import WorkflowACL +from cone.app.workflow import WorkflowState +from node.behaviors import Attributes +from node.behaviors import DictStorage +from node.behaviors import MappingAdopt +from node.behaviors import MappingNode +from node.behaviors import MappingOrder +from node.behaviors import NodeInit +from node.behaviors import OdictStorage +from node.utils import instance_property +from plumber import plumbing +from pyramid.i18n import TranslationStringFactory +from pyramid.security import ALL_PERMISSIONS +from pyramid.security import Allow +from pyramid.security import Deny +from pyramid.security import Everyone +from pyramid.threadlocal import get_current_request +from zope.component import adapter +from zope.interface import implementer -class ExamplePlugin(BaseNode): - pass +_ = TranslationStringFactory('cone.example') + + +@plumbing( + NodeInit, + MappingNode, + DictStorage, + TranslationBehavior) +class Translation: + ... + + +@plumbing( + AppNode, + WorkflowState, + WorkflowACL, + MappingAdopt, + Attributes, + NodeInit, + MappingNode, + MappingOrder, + OdictStorage) +class PublicationWorkflowNode: + workflow_name = 'publication' + workflow_tsf = staticmethod(_) + default_acl = [ + (Allow, 'system.Authenticated', ['view']), + (Allow, 'role:viewer', ['view', 'list']), + (Allow, 'role:editor', [ + 'view', 'list', 'add', 'edit', 'cut', 'copy', 'paste', + 'change_order' + ]), + (Allow, 'role:admin', [ + 'view', 'list', 'add', 'edit', 'delete', 'cut', 'copy', 'paste', + 'change_order', 'change_state', 'manage_permissions' + ]), + (Allow, 'role:manager', [ + 'view', 'list', 'add', 'edit', 'delete', 'cut', 'copy', 'paste', + 'change_order', 'change_state', 'manage_permissions', 'manage' + ]), + (Allow, Everyone, ['login']), + (Deny, Everyone, ALL_PERMISSIONS), + ] + + def __call__(self): + ... + + +@plumbing(PrincipalACL, CopySupport) +class BaseContainer(PublicationWorkflowNode): + role_inheritance = True + + @instance_property + def principal_roles(self): + return {} + + @property + def properties(self): + props = Properties() + props.in_navtree = True + props.default_content_tile = 'listing' + props.action_up = True + props.action_view = True + props.action_edit = True + props.action_list = True + props.action_sharing = True + props.action_move = True + props.action_add = True + return props + + @property + def metadata(self): + md = Metadata() + md.icon = self.nodeinfo.icon + md.title = self.attrs['title'].value + md.description = self.attrs['description'].value + md.creator = self.attrs['creator'] + md.created = self.attrs['created'] + md.modified = self.attrs['modified'] + return md + + +@node_info( + name='entry_folder', + title=_('folder', default='Folder'), + icon='bi-folder', + addables=['folder', 'item']) +class EntryFolder(BaseContainer): + + def __init__(self, name=None, parent=None): + super().__init__(name=name, parent=parent) + create_content(self) + + @property + def properties(self): + props = super().properties + props.mainmenu_display_children = True + return props + + +@node_info( + name='folder', + title=_('folder', default='Folder'), + icon='bi-folder', + addables=['folder', 'item']) +class Folder(BaseContainer): + + @property + def properties(self): + props = super().properties + props.action_delete = True + return props + + +@node_info( + name='item', + title=_('item', default='Item'), + icon='bi-file') +@plumbing(PrincipalACL) +@implementer(INavigationLeaf) +class Item(PublicationWorkflowNode): + role_inheritance = True + + @instance_property + def principal_roles(self): + return {} + + @property + def properties(self): + props = Properties() + props.in_navtree = True + props.action_up = True + props.action_view = True + props.action_edit = True + props.action_delete = True + props.action_sharing = True + return props + + @property + def metadata(self): + md = Metadata() + md.icon = self.nodeinfo.icon + md.title = self.attrs['title'].value + md.description = self.attrs['description'].value + md.creator = self.attrs['creator'] + md.created = self.attrs['created'] + md.modified = self.attrs['modified'] + return md + + +def create_content(node): + request = get_current_request() + name = node.name + + add_creation_metadata(request, node.attrs) + + title = node.attrs['title'] = Translation() + title['en'] = f'Folder {name[name.rfind("_") + 1:]}' + title['de'] = f'Ordner {name[name.rfind("_") + 1:]}' + + description = node.attrs['description'] = Translation() + description['en'] = f'Folder Description' + description['de'] = f'Ordner Beschreibung' + + for i in range(1, 21): + folder = node[f'folder_{i}'] = Folder() + add_creation_metadata(request, folder.attrs) + + title = folder.attrs['title'] = Translation() + title['en'] = f'Folder {i}' + title['de'] = f'Ordner {i}' + + description = folder.attrs['description'] = Translation() + description['en'] = f'Folder Description' + description['de'] = f'Ordner Beschreibung' + + for j in range(1, 21): + item = folder[f'item_{j}'] = Item() + add_creation_metadata(request, item.attrs) + + title = item.attrs['title'] = Translation() + title['en'] = f'Item {j}' + title['de'] = f'Object {j}' + + description = item.attrs['description'] = Translation() + description['en'] = f'Item Description' + description['de'] = f'Object Beschreibung' + + +@implementer(ILiveSearch) +@adapter(IApplicationNode) +class LiveSearch(object): + + def __init__(self, model): + self.model = model + + def search(self, request, query): + result = [] + for child in self.model.values(): + md = child.metadata + if ( + md.title.lower().find(query.lower()) > -1 or + md.description.lower().find(query.lower()) > -1 + ): + result.append({ + 'value': md.title, + 'target': make_url(request, node=child), + 'icon': md.icon, + 'description': md.description, + }) + return result diff --git a/examples/cone.example/src/cone/example/publication.zcml b/examples/cone.example/src/cone/example/publication.zcml new file mode 100644 index 00000000..788eda8c --- /dev/null +++ b/examples/cone.example/src/cone/example/publication.zcml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/cone.example/var/ugm/data/users/admin b/examples/cone.example/var/ugm/data/users/admin new file mode 100644 index 00000000..c6dc48f3 --- /dev/null +++ b/examples/cone.example/var/ugm/data/users/admin @@ -0,0 +1 @@ +fullname:Administrator \ No newline at end of file diff --git a/examples/cone.example/var/ugm/data/users/max b/examples/cone.example/var/ugm/data/users/max new file mode 100644 index 00000000..42493449 --- /dev/null +++ b/examples/cone.example/var/ugm/data/users/max @@ -0,0 +1 @@ +fullname:Max Mustermann \ No newline at end of file diff --git a/examples/cone.example/var/ugm/data/users/sepp b/examples/cone.example/var/ugm/data/users/sepp new file mode 100644 index 00000000..8f3a7054 --- /dev/null +++ b/examples/cone.example/var/ugm/data/users/sepp @@ -0,0 +1 @@ +fullname:Sepp Unterwurzacher \ No newline at end of file diff --git a/examples/cone.example/src/cone/example/browser/static/example.css b/examples/cone.example/var/ugm/groups similarity index 100% rename from examples/cone.example/src/cone/example/browser/static/example.css rename to examples/cone.example/var/ugm/groups diff --git a/examples/cone.example/var/ugm/roles b/examples/cone.example/var/ugm/roles new file mode 100644 index 00000000..45ed3fd8 --- /dev/null +++ b/examples/cone.example/var/ugm/roles @@ -0,0 +1,3 @@ +admin::manager +max::editor +sepp::viewer diff --git a/examples/cone.example/var/ugm/users b/examples/cone.example/var/ugm/users new file mode 100644 index 00000000..77f5d4bd --- /dev/null +++ b/examples/cone.example/var/ugm/users @@ -0,0 +1,3 @@ +admin:DTH11mwkW4ty9Jcer2CeGhqlX63whH6Nhi4MSi6V4im0xTdRs58Hwg== +max:hBVsuSCk67/zcfxGcljarlwM66s0WcAoRtqCOfd0R16n5weEvvcHPg== +sepp:6P+q3xCly0ikwT3cmDDIvhHAp0fjkRfy2knx6BJEExOodLumMtcNpA== diff --git a/include.mk b/include.mk new file mode 100644 index 00000000..64349d12 --- /dev/null +++ b/include.mk @@ -0,0 +1,48 @@ +############################################################################## +# custom bootstrap +############################################################################## + +# The bootstrap SCSS root source file. +# Default: scss/styles.scss +SCSS_BOOTSTRAP_SOURCE?=scss/bootstrap/bootstrap.scss + +# The target file for the compiled bootstrap Stylesheet. +# Default: scss/styles.css +SCSS_BOOTSTRAP_TARGET?=src/cone/app/browser/static/bootstrap/css/bootstrap.css + +# The target file for the compressed bootstrap Stylesheet. +# Default: scss/styles.min.css +SCSS_BOOTSTRAP_MIN_TARGET?=src/cone/app/browser/static/bootstrap/css/bootstrap.min.css + +.PHONY: bootstrap +bootstrap: $(NPM_TARGET) + @sass $(SCSS_OPTIONS) $(SCSS_BOOTSTRAP_SOURCE) $(SCSS_BOOTSTRAP_TARGET) + @sass $(SCSS_OPTIONS) --style compressed $(SCSS_BOOTSTRAP_SOURCE) $(SCSS_BOOTSTRAP_MIN_TARGET) + +############################################################################## +# example +############################################################################## + +.PHONY: example-install +example-install: install + @$(PYTHON_PACKAGE_COMMAND) install -e examples/cone.example + +.PHONY: example-run +example-run: + @cd examples/cone.example + @../../venv/bin/pserve example.ini + +EXAMPLE_GETTEXT_LOCALES_PATH=examples/cone.example/src/cone/example/locale +EXAMPLE_GETTEXT_DOMAIN=cone.example +EXAMPLE_GETTEXT_LANGUAGES=en de +EXAMPLE_LINGUA_SEARCH_PATH=examples/cone.example/src/cone/example +EXAMPLE_LINGUA_OPTIONS="-c examples/cone.example/lingua.cfg" + +PHONY: example-lingua +example-lingua: $(LINGUA_TARGET) + @make GETTEXT_LOCALES_PATH=$(EXAMPLE_GETTEXT_LOCALES_PATH) \ + GETTEXT_DOMAIN=$(EXAMPLE_GETTEXT_DOMAIN) \ + GETTEXT_LANGUAGES="$(EXAMPLE_GETTEXT_LANGUAGES)" \ + LINGUA_SEARCH_PATH=$(EXAMPLE_LINGUA_SEARCH_PATH) \ + LINGUA_OPTIONS=$(EXAMPLE_LINGUA_OPTIONS) \ + lingua diff --git a/js/karma.conf.js b/js/karma.conf.js deleted file mode 100644 index 5e50c90b..00000000 --- a/js/karma.conf.js +++ /dev/null @@ -1,65 +0,0 @@ -process.env.CHROME_BIN = '/usr/bin/chromium'; - -const node_modules = '../../node_modules'; - -const jquery_files = [{ - pattern: `${node_modules}/jquery/src/**/*.js`, - type: 'module', - included: true -}]; - -const treibstoff_files = [{ - pattern: `../../sources/treibstoff/src/*.js`, - type: 'module', - included: true -}]; - -const test_files = [{ - pattern: '../src/*.js', - type: 'module', - included: false -}, { - pattern: '../tests/test_*.js', - type: 'module' -}]; - -const files = [].concat( - jquery_files, - treibstoff_files, - test_files -) - -let preprocessors = {}; -preprocessors['../../sources/treibstoff/src/**/*.js'] = ['module-resolver']; -preprocessors['../src/*.js'] = ['coverage', 'module-resolver']; -preprocessors['../tests/*.js'] = ['coverage', 'module-resolver']; - -// karma config -module.exports = function(config) { - config.set({ - basePath: 'karma', - singleRun: true, - frameworks: [ - 'qunit' - ], - files: files, - browsers: [ - 'ChromeHeadless' - ], - singlerun: true, - reporters: [ - 'progress', - 'coverage' - ], - preprocessors: preprocessors, - moduleResolverPreprocessor: { - addExtension: 'js', - customResolver: null, - ecmaVersion: 6, - aliases: { - jquery: `${node_modules}/jquery/src/jquery.js`, - treibstoff: '../../sources/treibstoff/src/treibstoff.js' - } - } - }); -}; diff --git a/js/rollup.conf.js b/js/rollup.conf.js index a0f69f51..330bdad3 100644 --- a/js/rollup.conf.js +++ b/js/rollup.conf.js @@ -3,34 +3,23 @@ import terser from '@rollup/plugin-terser'; const out_dir = 'src/cone/app/browser/static/cone'; -const default_outro = ` -window.cone = window.cone || {}; -Object.assign(window.cone, exports); -` - -const protected_outro = default_outro + ` +const outro = ` window.createCookie = createCookie; window.readCookie = readCookie; `; -const protected_globals = { +const globals = { jquery: 'jQuery', treibstoff: 'treibstoff' }; -const public_globals = { - jquery: 'jQuery', - treibstoff: 'treibstoff', - Bloodhound: 'Bloodhound' -}; - -const create_bundle = function(name, globals, outro, debug) { +export default args => { let conf = { - input: `js/src/bundles/${name}.js`, + input: `js/src/bundle.js`, plugins: [cleanup()], output: [{ - file: `${out_dir}/cone.app.${name}.js`, - name: `cone_app_${name}`, + file: `${out_dir}/cone.app.js`, + name: 'cone', format: 'iife', outro: outro, globals: globals, @@ -41,10 +30,10 @@ const create_bundle = function(name, globals, outro, debug) { 'treibstoff' ] }; - if (debug !== true) { + if (args.configDebug !== true) { conf.output.push({ - file: `${out_dir}/cone.app.${name}.min.js`, - name: `cone_app_${name}`, + file: `${out_dir}/cone.app.min.js`, + name: 'cone', format: 'iife', plugins: [terser()], outro: outro, @@ -53,12 +42,4 @@ const create_bundle = function(name, globals, outro, debug) { }); } return conf; -} - -export default args => { - let debug = args.configDebug; - return [ - create_bundle('public', public_globals, default_outro, debug), - create_bundle('protected', protected_globals, protected_outro, debug) - ]; }; diff --git a/js/src/bundle.js b/js/src/bundle.js new file mode 100644 index 00000000..098656a9 --- /dev/null +++ b/js/src/bundle.js @@ -0,0 +1,69 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; + +import { + BatchedItemsSize, + BatchedItemsSearch +} from './batcheditems.js'; +import {ColorMode} from './colormode.js'; +import {ColorToggler} from './colormode.js'; +import {CopySupport} from './copysupport.js'; +import {KeyBinder} from './keybinder.js'; +import {LiveSearch} from './livesearch.js'; +import { + ReferenceBrowserLoader, + ReferenceHandle +} from './referencebrowser.js'; +import {Scrollbar} from './scrollbar.js'; +import {Sharing} from './sharing.js'; +import {Sidebar} from './sidebar.js'; +import {TableToolbar} from './tabletoolbar.js'; +import {Translation} from './translation.js'; +import {MainMenu} from './mainmenu.js'; +import {Header} from './header.js'; +import {MainArea} from './layout.js'; +import {Logo} from './logo.js'; +import {NavTree} from './navtree.js'; + +export * from './batcheditems.js'; +export * from './colormode.js'; +export * from './copysupport.js'; +export * from './globals.js'; +export * from './header.js'; +export * from './keybinder.js'; +export * from './layout.js'; +export * from './livesearch.js'; +export * from './logo.js'; +export * from './mainmenu.js'; +export * from './navtree.js'; +export * from './referencebrowser.js'; +export * from './scrollbar.js'; +export * from './selectable.js'; +export * from './sharing.js'; +export * from './sidebar.js'; +export * from './tabletoolbar.js'; +export * from './translation.js'; +export * from './utils.js'; + +$(function() { + new KeyBinder(); + new ColorMode(); + + ts.ajax.register(BatchedItemsSize.initialize, true); + ts.ajax.register(BatchedItemsSearch.initialize, true); + ts.ajax.register(CopySupport.initialize, true); + ts.ajax.register(ReferenceBrowserLoader.initialize, true); + ts.ajax.register(ReferenceHandle.initialize, true); + ts.ajax.register(Sharing.initialize, true); + ts.ajax.register(TableToolbar.initialize, true); + ts.ajax.register(Translation.initialize, true); + ts.ajax.register(ColorToggler.initialize, true); + ts.ajax.register(Scrollbar.initialize, true); + ts.ajax.register(LiveSearch.initialize, true); + ts.ajax.register(MainMenu.initialize, true); + ts.ajax.register(Logo.initialize, true); + ts.ajax.register(Header.initialize, true); + ts.ajax.register(MainArea.initialize, true); + ts.ajax.register(Sidebar.initialize, true); + ts.ajax.register(NavTree.initialize, true); +}); diff --git a/js/src/bundles/protected.js b/js/src/bundles/protected.js deleted file mode 100644 index 297c10c4..00000000 --- a/js/src/bundles/protected.js +++ /dev/null @@ -1,40 +0,0 @@ -import $ from 'jquery'; -import ts from 'treibstoff'; - -import { - BatchedItemsSize, - BatchedItemsSearch -} from '../batcheditems.js'; -import {CopySupport} from '../copysupport.js'; -import {KeyBinder} from '../keybinder.js'; -import { - ReferenceBrowserLoader, - ReferenceHandle -} from '../referencebrowser.js'; -import {Sharing} from '../sharing.js'; -import {TableToolbar} from '../tabletoolbar.js'; -import {Translation} from '../translation.js' - -export * from '../batcheditems.js'; -export * from '../copysupport.js'; -export * from '../keybinder.js'; -export * from '../keybinder.js'; -export * from '../referencebrowser.js'; -export * from '../selectable.js'; -export * from '../sharing.js'; -export * from '../tabletoolbar.js'; -export * from '../translation.js'; -export * from '../utils.js'; - -$(function() { - new KeyBinder(); - - ts.ajax.register(BatchedItemsSize.initialize, true); - ts.ajax.register(BatchedItemsSearch.initialize, true); - ts.ajax.register(CopySupport.initialize, true); - ts.ajax.register(ReferenceBrowserLoader.initialize, true); - ts.ajax.register(ReferenceHandle.initialize, true); - ts.ajax.register(Sharing.initialize, true); - ts.ajax.register(TableToolbar.initialize, true); - ts.ajax.register(Translation.initialize, true); -}); diff --git a/js/src/bundles/public.js b/js/src/bundles/public.js deleted file mode 100644 index 6b091d52..00000000 --- a/js/src/bundles/public.js +++ /dev/null @@ -1,10 +0,0 @@ -import $ from 'jquery'; -import ts from 'treibstoff'; - -import {LiveSearch} from '../livesearch.js'; - -export * from '../livesearch.js'; - -$(function() { - ts.ajax.register(LiveSearch.initialize, true); -}); diff --git a/js/src/colormode.js b/js/src/colormode.js new file mode 100644 index 00000000..bd95766d --- /dev/null +++ b/js/src/colormode.js @@ -0,0 +1,152 @@ +import ts from 'treibstoff'; + +/** + * Class to manage color modes (light and dark themes). + */ +export class ColorMode { + + /** + * The media query for the user's preferred color scheme. + * @returns {MediaQueryList} + */ + static get media_query() { + return window.matchMedia('(prefers-color-scheme: dark)'); + } + + /** + * The stored theme from local storage. + * @returns {string | null} + */ + static get stored_theme() { + return localStorage.getItem('cone-app-color-theme'); + } + + /** + * @param {string} theme The theme to store in local storage. + */ + static set stored_theme(theme) { + localStorage.setItem('cone-app-color-theme', theme); + } + + /** + * The user's preferred theme ('dark' or 'light'). + * @returns {string} + */ + static get preferred_theme() { + if (this.stored_theme) { + return this.stored_theme; + } + return this.media_query.matches ? 'dark' : 'light'; + } + + /** + * Adds an event listener to watch for changes in the media query. + * @param {function} handle The callback function to handle changes. + */ + static watch(handle) { + this.media_query.addEventListener('change', handle); + } + + /** + * Sets the current theme on the document. + * @param {string} theme The theme to set ('dark', 'light', or 'auto'). + */ + static set_theme(theme) { + const elem = document.documentElement; + if (theme === 'auto' && this.media_query.matches) { + // Set to dark if 'auto' and dark mode is preferred + elem.setAttribute('data-bs-theme', 'dark'); + } else { + elem.setAttribute('data-bs-theme', theme); + } + } + + /** + * Initializes the ColorMode instance and sets the preferred theme. + */ + constructor() { + ColorMode.bind(); + ColorMode.set_theme(ColorMode.preferred_theme); + } + + /** + * Binds the change event listener to update the theme. + */ + static bind() { + this.boundCallback = this.callback.bind(this); + this.watch(this.boundCallback); + } + + /** + * Static callback function to handle the color scheme change. + */ + static callback() { + const stored_theme = this.stored_theme; + if (stored_theme !== 'light' && stored_theme !== 'dark') { + ColorMode.set_theme(ColorMode.preferred_theme); + } + } + + /** + * Static method to remove the event listener and clean up. + */ + static unbind() { + if (this.boundCallback) { + this.media_query.removeEventListener('change', this.boundCallback); + document.documentElement.removeAttribute('data-bs-theme'); + } + } +} + + +/** + * Class to toggle the color theme based on user input (visible as a Switch). + */ +export class ColorToggler extends ts.ChangeListener { + + /** + * Initializes the ColorToggler and binds the toggle switch. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#colortoggle-switch', context); + if (!elem) { + return; + } + new ColorToggler(elem); + } + + /** + * @param {Element} elem The toggle switch element. + */ + constructor(elem) { + super({ elem: elem }); + this.update = this.update.bind(this); + this.update(); + ColorMode.watch(this.update()); + } + + /** + * Updates the toggle switch state based on the preferred theme. + */ + update() { + const preferred_theme = ColorMode.preferred_theme; + const elem = this.elem; + const checked = elem.is(':checked'); + + if (preferred_theme === 'dark' && !checked) { + elem.prop('checked', true); + } else if (preferred_theme === 'light' && checked) { + elem.prop('checked', false); + } + } + + /** + * Handles changes when the toggle is switched. + */ + on_change() { + const theme = this.elem.is(':checked') ? 'dark' : 'light'; + ColorMode.set_theme(theme); + ColorMode.stored_theme = theme; + } +} diff --git a/js/src/cone.js b/js/src/cone.js index 421a4e05..2d35b609 100644 --- a/js/src/cone.js +++ b/js/src/cone.js @@ -1,25 +1,43 @@ import * as batcheditems from './batcheditems.js'; +import * as colormode from './colormode.js'; import * as copysupport from './copysupport.js'; +import * as globals from './globals.js'; +import * as header from './header.js'; import * as keybinder from './keybinder.js'; +import * as layout from './layout.js'; import * as livesearch from './livesearch.js'; +import * as logo from './logo.js'; +import * as mainmenu from './mainmenu.js'; +import * as navtree from './navtree.js'; import * as referencebrowser from './referencebrowser.js'; +import * as scrollbar from './scrollbar.js'; import * as selectable from './selectable.js'; -import * as settingstabs from './settingstabs.js'; import * as sharing from './sharing.js'; +import * as sidebar from './sidebar.js'; import * as tabletoolbar from './tabletoolbar.js'; +import * as translation from './translation.js'; import * as utils from './utils.js'; let api = {}; Object.assign(api, batcheditems); +Object.assign(api, colormode); Object.assign(api, copysupport); +Object.assign(api, globals); +Object.assign(api, header); Object.assign(api, keybinder); +Object.assign(api, layout); Object.assign(api, livesearch); +Object.assign(api, logo); +Object.assign(api, mainmenu); +Object.assign(api, navtree); Object.assign(api, referencebrowser); +Object.assign(api, scrollbar); Object.assign(api, selectable); -Object.assign(api, settingstabs); Object.assign(api, sharing); +Object.assign(api, sidebar); Object.assign(api, tabletoolbar); +Object.assign(api, translation); Object.assign(api, utils); let cone = api; diff --git a/js/src/globals.js b/js/src/globals.js new file mode 100644 index 00000000..947cbb72 --- /dev/null +++ b/js/src/globals.js @@ -0,0 +1,25 @@ +import ts from 'treibstoff'; + +/** + * Class to manage global events. + */ +export class GlobalEvents extends ts.Events { + + /** + * Gets triggered when sidebar is resized. + * + * @param {Sidebar} inst + */ + on_sidebar_resize(inst) { + } + + /** + * Gets triggered when main area toggles between compact and full mode. + * + * @param {MainArea} inst + */ + on_main_area_mode(inst) { + } +} + +export const global_events = new GlobalEvents(); diff --git a/js/src/header.js b/js/src/header.js new file mode 100644 index 00000000..29fd382b --- /dev/null +++ b/js/src/header.js @@ -0,0 +1,169 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import { ScrollbarY } from './scrollbar.js'; +import { LayoutAware } from './layout.js'; + +/** + * Class to manage the header layout and interactions. + * @extends LayoutAware + */ +export class Header extends LayoutAware { + + /** + * Initializes the Header instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#header-main', context); + if (!elem) { + return; + } + new Header(elem); + } + + /** + * @param {Element} elem The main header element. + */ + constructor(elem) { + super(elem); + this.elem = elem; + this.logo_placeholder = ts.query_elem('#header-logo-placeholder', elem); + this.header_content = ts.query_elem('#header-content', elem); + this.navbar_content_wrapper = ts.query_elem('#navbar-content-wrapper', elem); + this.navbar_content = ts.query_elem('#navbar-content', elem); + this.navbar_toggler = ts.query_elem('#navbar-toggler', this.elem); + this.personal_tools = ts.query_elem('#personaltools', elem); + this.mainmenu = ts.query_elem('#mainmenu', elem); + this.mainmenu_elems = $('.nav-link.dropdown-toggle', this.mainmenu); + + this.render_mobile_scrollbar = this.render_mobile_scrollbar.bind(this); + this.mainmenu_elems.each((i, el) => { + $(el).on('shown.bs.dropdown', this.render_mobile_scrollbar); + $(el).on('hidden.bs.dropdown', this.render_mobile_scrollbar); + }); + + this.set_mobile_menu_open = this.set_mobile_menu_open.bind(this); + this.set_mobile_menu_closed = this.set_mobile_menu_closed.bind(this); + this.bind(); + } + + /** + * Destroys the Header instance and cleans up event listeners. + */ + destroy() { + super.destroy(); + if (this.mobile_scrollbar) { + this.mobile_scrollbar.destroy(); + this.mobile_scrollbar = null; + } + this.mainmenu_elems.each((i, el) => { + $(el).off('shown.bs.dropdown', this.render_mobile_scrollbar); + $(el).off('hidden.bs.dropdown', this.render_mobile_scrollbar); + }); + const wrapper = this.navbar_content_wrapper; + wrapper.off('show.bs.collapse shown.bs.collapse', this.set_mobile_menu_open); + wrapper.off('hide.bs.collapse hidden.bs.collapse', this.set_mobile_menu_closed); + } + + /** + * Renders the mobile scrollbar if in compact mode. + */ + render_mobile_scrollbar() { + if (this.is_compact && this.mobile_scrollbar) { + this.mobile_scrollbar.render(); + } + } + + /** + * Binds event listeners for bootstrap navbar collapse events. + */ + bind() { + const wrapper = this.navbar_content_wrapper; + wrapper.on('show.bs.collapse shown.bs.collapse', this.set_mobile_menu_open); + wrapper.on('hidden.bs.collapse', this.set_mobile_menu_closed); + } + + /** + * Sets a header class to indicate the mobile menu is open. + */ + set_mobile_menu_open() { + this.elem.addClass('mobile-menu-open'); + } + + /** + * Removes a header class to indicate the mobile menu is closed. + */ + set_mobile_menu_closed() { + this.elem.removeClass('mobile-menu-open'); + } + + /** + * Handles changes (scrollbar and bootstrap dropdown) + * in the compact state of the header. + * @param {boolean} val + */ + on_is_compact(val) { + if (this.mobile_scrollbar) { + // remove mobile scrollbar + this.navbar_content.removeClass('scrollable-content'); + this.mobile_scrollbar.destroy(); + this.mobile_scrollbar = null; + } + if (val) { + this.elem.removeClass('full').removeClass('navbar-expand'); + this.elem.addClass('compact'); + + // create mobile scrollbar + this.navbar_content.addClass('scrollable-content'); + this.mobile_scrollbar = new ScrollbarY(this.navbar_content_wrapper); + + this.navbar_content_wrapper.on('shown.bs.collapse', () => { + // disable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'none'); + this.mobile_scrollbar.render(); + }); + this.navbar_content_wrapper.on('hide.bs.collapse', () => { + // enable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'auto'); + this.mobile_scrollbar.scrollbar.hide(); + }); + } else { + this.elem.removeClass('compact'); + this.elem.addClass('full').addClass('navbar-expand'); + } + } + + /** + * Handles changes in the super compact state of the header. + * @param {boolean} val + */ + on_is_super_compact(val) { + const in_navbar_content = ts.query_elem( + '#personaltools', + this.navbar_content + ) !== null; + if (val) { + if (!in_navbar_content) { + this.personal_tools.detach().appendTo(this.navbar_content); + } + } else { + if (in_navbar_content) { + this.personal_tools.detach().prependTo(this.header_content); + } + // close any header dropdowns + $(".dropdown-menu.show").removeClass('show'); + } + } + + /** + * Handles changes in the sidebar collapsed state. + * @param {boolean} val + */ + on_is_sidebar_collapsed(val) { + if (val) { + this.logo_placeholder.show(); + } else { + this.logo_placeholder.hide(); + } + } +} diff --git a/js/src/keybinder.js b/js/src/keybinder.js index fbe67cd1..f7fe4788 100644 --- a/js/src/keybinder.js +++ b/js/src/keybinder.js @@ -7,7 +7,6 @@ export let keys = { /** * XXX: Use ``ts.KeyState`` instead. - * Need a mechanism to attach and unload instances with ``ts.ajax`` first. */ export class KeyBinder { @@ -31,7 +30,7 @@ export class KeyBinder { switch (e.keyCode || e.which) { case 16: keys.shift_down = false; - break; + break; case 17: keys.ctrl_down = false; break; diff --git a/js/src/layout.js b/js/src/layout.js new file mode 100644 index 00000000..eff7e4c4 --- /dev/null +++ b/js/src/layout.js @@ -0,0 +1,228 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import { global_events } from './globals.js'; + +/** + * Class to manage the main area of the application. + * @extends ts.Events + */ +export class MainArea extends ts.Events { + + /** + * Initializes the MainArea instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#main-area', context); + if (!elem) { + return; + } + new MainArea(elem); + } + + /** + * @param {Element} elem The main area element. + */ + constructor(elem) { + super(); + this.elem = elem; + + new ts.Property(this, 'is_compact', null); + new ts.Property(this, 'is_super_compact', null); + + this.set_mode = this.set_mode.bind(this); + global_events.on('on_sidebar_resize', this.set_mode); + $(window).on('resize', this.set_mode); + this.set_mode(); + + ts.ajax.attach(this, elem); + } + + /** + * Destroys the MainArea instance and cleans up event listeners. + */ + destroy() { + $(window).off('resize', this.set_mode); + global_events.off('on_sidebar_resize', this.set_mode); + } + + /** + * Sets the compact state based on the current width of the main area element. + */ + set_mode() { + this.is_compact = this.elem.outerWidth() < 992; // tablet + this.is_super_compact = this.elem.outerWidth() < 576; // mobile + } + + /** + * Handles changes in the compact state of the main area. + * @param {boolean} val + */ + on_is_compact(val) { + if (val) { + this.elem.removeClass('full'); + this.elem.addClass('compact'); + } else { + this.elem.removeClass('compact'); + this.elem.addClass('full'); + } + + global_events.trigger('on_main_area_mode', this); + } + + /** + * Handles changes in the super compact state of the main area. + * @param {boolean} val + */ + on_is_super_compact(val) { + if (val) { + this.elem.addClass('super-compact'); + } else { + this.elem.removeClass('super-compact'); + } + + global_events.trigger('on_main_area_mode', this); + } +} + +/** + * Base class for subclasses that inherit layout-aware behavior. + * @extends ts.Events + */ +export class LayoutAware extends ts.Events { + + /** + * @param {Element} elem + */ + constructor(elem) { + super(); + this.elem = elem; + + new ts.Property(this, 'is_compact', null); + new ts.Property(this, 'is_super_compact', null); + new ts.Property(this, 'is_sidebar_collapsed', null); + + this.set_mode = this.set_mode.bind(this); + global_events.on('on_main_area_mode', this.set_mode); + + this.on_sidebar_resize = this.on_sidebar_resize.bind(this); + global_events.on('on_sidebar_resize', this.on_sidebar_resize); + + ts.ajax.attach(this, elem); + } + + /** + * Destroys the LayoutAware instance and cleans up event listeners. + */ + destroy() { + global_events.off('on_main_area_mode', this.set_mode); + global_events.off('on_sidebar_resize', this.on_sidebar_resize); + } + + /** + * Sets the layout mode based on the main area's state. + * @param {} inst + * @param {MainArea} mainarea + */ + set_mode(inst, mainarea) { + this.is_compact = mainarea.is_compact; + this.is_super_compact = mainarea.is_super_compact; + } + + /** + * Handles changes in the compact state of the main area layout. + * @param {boolean} val Indicates if the main area layout is compact. + */ + on_is_compact(val) { + if (val) { + this.elem.removeClass('full'); + this.elem.addClass('compact'); + } else { + this.elem.removeClass('compact'); + this.elem.addClass('full'); + } + } + + /** + * Handles changes in the super compact state of the main area layout. + * @param {boolean} val Indicates if the main area layout is super compact. + */ + on_is_super_compact(val) { + if (val) { + this.elem.addClass('super-compact'); + } else { + this.elem.removeClass('super-compact'); + } + } + + /** + * Handles changes on the sidebar resize event. + * @param {} inst + * @param {Object} sidebar + */ + on_sidebar_resize(inst, sidebar) { + this.is_sidebar_collapsed = sidebar.collapsed; + } + + /** + * Handles changes in the sidebar collapsed state. + * @param {boolean} val + */ + on_is_sidebar_collapsed(val) { + // ... + } +} + +/** + * A mixin to add window resize awareness to a class. + * + * This mixin expects that the class it extends has an `elem` property. + * It attaches a resize event listener to the window that triggers the + * `on_window_resize` method defined in the subclass. + * + * @param {class} Base - The base class to extend. + * @returns {class} + */ +export const ResizeAware = (Base) => class extends Base { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + + if (this.elem) { + ts.ajax.attach(this, this.elem); + } + + this.on_window_resize = this.on_window_resize.bind(this); + + $(window).on('resize', this.on_window_resize); + } + + /** + * Handler for the window resize event. + * + * This method should be overridden in the subclass to define custom resize + * behavior. + */ + on_window_resize(evt) { + // Call `on_window_resize` of the base class, if it exists + if (super.on_window_resize) { + super.on_window_resize(evt); + } + } + + /** + * Removes the resize event handler from the window. + */ + destroy() { + try { + super.destroy(); + } catch (error) { + console.warn(error); + } finally { + $(window).off('resize', this.on_window_resize); + } + } +}; diff --git a/js/src/livesearch.js b/js/src/livesearch.js index 570f3d3e..8ed226e1 100644 --- a/js/src/livesearch.js +++ b/js/src/livesearch.js @@ -4,8 +4,8 @@ import ts from 'treibstoff'; export class LiveSearch { static initialize(context, factory=null) { - let elem = $('input#search-text', context); - if (!elem.length) { + const elem = ts.query_elem('input#search-text', context); + if (!elem) { return; } if (factory === null) { @@ -16,40 +16,113 @@ export class LiveSearch { constructor(elem) { this.elem = elem; - let source = new Bloodhound({ - datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), - queryTokenizer: Bloodhound.tokenizers.whitespace, - remote: 'livesearch?term=%QUERY' + this.target = `${elem.data('search-target')}/livesearch`; + this.content = $('#content'); + this.result = null; + + this._term = ''; + this._minlen = 3; + this._delay = 250; + this._timeout_event = null; + this._in_progress = false; + + this.on_keydown = this.on_keydown.bind(this); + this.on_change = this.on_change.bind(this); + this.on_result = this.on_result.bind(this); + + elem.on('keydown', this.on_keydown); + elem.on('change', this.on_change); + } + + search() { + this._in_progress = true; + ts.http_request({ + url: this.target, + params: {term: this._term}, + type: 'json', + success: this.on_result }); - source.initialize(); - this.render_suggestion = this.render_suggestion.bind(this); - elem.typeahead(null, { - name: 'livesearch', - displayKey: 'value', - source: source.ttAdapter(), - templates: { - suggestion: this.render_suggestion, - empty: '
No search results
' + this._in_progress = false; + } + + render_no_results() { + ts.compile_template(this, ` +
No search results
+ `, this.result); + } + + render_suggestion(item) { + ts.compile_template(this, ` +
+
+ + + ${item.value} + +
+

+ ${item.description === undefined ? '' : item.description} +

+
+ `, this.result); + } + + on_result(data, status, request) { + this.content.empty(); + ts.compile_template(this, ` +
+
+

Search results for "${this._term}"

+
+
+ `, this.content); + if (!data.length) { + this.render_no_results(); + } else { + ts.compile_template(this, ` +

+ ${data.length} Results +

+ `, this.result); + for (const item of data) { + this.render_suggestion(item); } - }); - this.on_select = this.on_select.bind(this); - let event = 'typeahead:selected'; - elem.off(event).on(event, this.on_select); + } + this.result.tsajax(); } - on_select(evt, suggestion, dataset) { - if (!suggestion.target) { - console.log('No suggestion target defined.'); + on_keydown(evt) { + if (evt.keyCode === 13) { return; } - ts.ajax.trigger( - 'contextchanged', - '#layout', - suggestion.target - ); + ts.clock.schedule_frame(() => { + if (this._term !== this.elem.val()) { + this.elem.trigger('change'); + } + }); } - render_suggestion(suggestion) { - return `${suggestion.value}`; + on_change(evt) { + if (this._in_progress) { + return; + } + const term = this.elem.val(); + if (this._term === term) { + return; + } + this._term = term; + if (this._term.length < this._minlen) { + return; + } + if (this._timeout_event !== null) { + this._timeout_event.cancel(); + } + this._timeout_event = ts.clock.schedule_timeout(() => { + this._timeout_event = null; + this.search(); + }, this._delay); } } diff --git a/js/src/logo.js b/js/src/logo.js new file mode 100644 index 00000000..1b35e759 --- /dev/null +++ b/js/src/logo.js @@ -0,0 +1,34 @@ +import ts from 'treibstoff'; +import { LayoutAware } from './layout.js'; + +/** + * Class to manage the header logo. + * @extends LayoutAware + */ +export class Logo extends LayoutAware { + + /** + * Initializes the Logo instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#header-logo', context); + if (!elem) { + return; + } + new Logo(elem); + } + + /** + * Handles the logo text color based on the sidebar collapsed state. + * @param {Object} inst + * @param {Object} sidebar + */ + on_sidebar_resize(inst, sidebar) { + if (sidebar.collapsed) { + this.elem.removeClass('text-white'); + } else { + this.elem.addClass('text-white'); + } + } +} diff --git a/js/src/mainmenu.js b/js/src/mainmenu.js new file mode 100644 index 00000000..bd4b4513 --- /dev/null +++ b/js/src/mainmenu.js @@ -0,0 +1,140 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import { LayoutAware } from './layout.js'; + +/** + * Class to manage the main menu of the application. + * @extends LayoutAware + */ +export class MainMenu extends LayoutAware { + + /** + * Initializes the MainMenu instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#mainmenu', context); + if (!elem) { + return; + } + new MainMenu(elem); + } + + /** + * @param {Element} elem + */ + constructor(elem) { + super(elem); + this.elem = elem; + this.scrollbar = elem.data('scrollbar'); + this.elems = $('.nav-link.dropdown-toggle', elem); + this.open_dropdown = null; + + this.on_show_dropdown_desktop = this.on_show_dropdown_desktop.bind(this); + this.on_hide_dropdown_desktop = this.on_hide_dropdown_desktop.bind(this); + this.hide_dropdowns = this.hide_dropdowns.bind(this); + this.scrollbar.on('on_position', this.hide_dropdowns); + } + + /** + * Returns the main menu element's outer height. + */ + get height() { + return this.elem.outerHeight(true); + } + + /** + * Handles changes in the sidebar resize event. + * @param {} inst + * @param {Object} sidebar + */ + on_sidebar_resize(inst, sidebar) { + super.on_sidebar_resize(inst, sidebar); + // defer to next frame to ensure elements have correct dimensions + requestAnimationFrame(() => { + this.scrollbar.render(); + }); + } + + /** + * Handles changes in the compact state of the main menu. + * Binds modified dropdown behavior to bootstrap dropdowns. + * @param {boolean} val + */ + on_is_compact(val) { + this.hide_dropdowns(); + + if (val) { + this.scrollbar.off('on_position', this.hide_dropdowns); + this.bind_dropdowns_mobile(); + } else { + this.bind_dropdowns_desktop(); + this.scrollbar.on('on_position', this.hide_dropdowns); + } + } + + /** + * Handles the event when a dropdown is shown on desktop. + * Sets the dropdown position manually due to position being set as + * 'static' in css (to avoid dropdowns being cut by overflow: hidden) + * @param {Event} evt + */ + on_show_dropdown_desktop(evt) { + const el = evt.target; + this.open_dropdown = el; + + // prevent element being cut by scrollbar while open + const dropdown = $(el).siblings('ul.dropdown-menu'); + dropdown.css({ + top: `${this.height - 1}px`, // remove border from position + left: `${$(el).offset().left}px` + }); + } + + /** + * Handles the event when a dropdown is hidden on desktop. + * @param {Event} evt + */ + on_hide_dropdown_desktop(evt) { + const el = evt.target; + // return if the click that closes the dropdown opens another dropdown + if (this.open_dropdown !== el) { + return; + } + this.open_dropdown = null; + } + + /** + * Binds the dropdown events for desktop view. + */ + bind_dropdowns_desktop() { + // this.elems.on(...) deprecated in jquery 4.0.0-beta.2 (?) + this.elem.on('shown.bs.dropdown', '.nav-link.dropdown-toggle', this.on_show_dropdown_desktop); + this.elem.on('hidden.bs.dropdown', '.nav-link.dropdown-toggle', this.on_hide_dropdown_desktop); + } + + /** + * Unbinds the dropdown events for mobile view. + */ + bind_dropdowns_mobile() { + this.elem.off('shown.bs.dropdown', '.nav-link.dropdown-toggle', this.on_show_dropdown_desktop); + this.elem.off('hidden.bs.dropdown', '.nav-link.dropdown-toggle', this.on_hide_dropdown_desktop); + } + + /** + * Hides all dropdowns in the main menu. + */ + hide_dropdowns() { + this.elems.each((i, el) => { + $(el).dropdown('hide'); + }); + } + + destroy() { + super.destroy(); + this.elem.off('shown.bs.dropdown', '.nav-link.dropdown-toggle', this.on_show_dropdown_desktop); + this.elem.off('hidden.bs.dropdown', '.nav-link.dropdown-toggle', this.on_hide_dropdown_desktop); + this.scrollbar.off('on_position', this.hide_dropdowns); + this.scrollbar.destroy(); + } +} diff --git a/js/src/navtree.js b/js/src/navtree.js new file mode 100644 index 00000000..31c7424b --- /dev/null +++ b/js/src/navtree.js @@ -0,0 +1,65 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; + +/** + * Class to manage a collapsible navigation tree component. + */ +export class NavTree { + + /** + * Initializes the NavTree instance for a given context. + * @param {Element} context - DOM context. + */ + static initialize(context) { + const elem = ts.query_elem('#navtree', context); + if (!elem) { + return; + } + new NavTree(elem); + } + + /** + * Constructs a NavTree instance and sets up its behavior. + * @param {Element} elem - The root ul element of the navigation tree. + */ + constructor(elem) { + this.elem = elem; + this.dropdown_elem = $('#navigation-collapse', elem); + + // Expand menu if previously opened. + if (localStorage.getItem('cone.app.navtree.open')) { + this.dropdown_elem.addClass('show'); + } + + this.set_menu_open = this.set_menu_open.bind(this); + this.set_menu_closed = this.set_menu_closed.bind(this); + + this.dropdown_elem.on('shown.bs.collapse', this.set_menu_open); + this.dropdown_elem.on('hidden.bs.collapse', this.set_menu_closed); + + ts.ajax.attach(this, elem); + } + + /** + * Handles the event when the navigation menu is opened. + * Stores the menu state in localStorage. + * @param {Event} e - Event object. + */ + set_menu_open(e) { + localStorage.setItem('cone.app.navtree.open', 'true'); + } + + /** + * Handles the event when the navigation menu is closed. + * Removes the menu state from localStorage. + * @param {Event} e - Event object. + */ + set_menu_closed(e) { + localStorage.removeItem('cone.app.navtree.open'); + } + + destroy() { + this.dropdown_elem.off(); + this.elem.off(); + } +} diff --git a/js/src/referencebrowser.js b/js/src/referencebrowser.js index 8820a7f2..d2c48ede 100644 --- a/js/src/referencebrowser.js +++ b/js/src/referencebrowser.js @@ -158,6 +158,8 @@ export class ReferenceBrowserLoader { evt.preventDefault(); let ol = ts.ajax.overlay({ action: 'referencebrowser', + title: 'Referencebrowser', + css: 'modal-lg', target: this.wrapper.attr('ajax:target'), on_complete: this.on_complete.bind(this) }); diff --git a/js/src/scrollbar.js b/js/src/scrollbar.js new file mode 100644 index 00000000..932009a3 --- /dev/null +++ b/js/src/scrollbar.js @@ -0,0 +1,485 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import {ResizeAware} from './layout.js'; + +/** + * Class representing a generic scrollbar. + * @extends ts.Motion + */ +export class Scrollbar extends ts.Motion { + + /** + * Initializes all scrollable elements in the given context. + * @param {Element} context + */ + static initialize(context) { + $('.scrollable-x', context).each(function() { + new ScrollbarX($(this)); + }); + $('.scrollable-y', context).each(function() { + new ScrollbarY($(this)); + }); + } + + /** + * Creates an instance of a Scrollbar. + * @param {jQuery} elem + */ + constructor(elem) { + super(elem); + + this.elem = elem; + if (this.elem.data('scrollbar')) { + console.warn('cone.app: Only one Scrollbar can be bound to each element.'); + return; + } + this.elem.data('scrollbar', this); + this.content = ts.query_elem('> .scrollable-content', elem); + + this.on_scroll = this.on_scroll.bind(this); + this.on_click = this.on_click.bind(this); + this.on_hover = this.on_hover.bind(this); + this.on_window_resize = this.on_window_resize.bind(this); + + this.compile(); + this.position = 0; + this.scroll_step = 50; // Scroll step in pixels + new ts.Property(this, 'disabled', false); + + ts.clock.schedule_frame(() => this.render()); + + const is_mobile = $(window).width() <= 768; // bs5 small/medium breakpoint + new ts.Property(this, 'is_mobile', is_mobile); + } + + /** + * Handles window resize event to adjust the scrollbar. + * Invoked by ResizeAware mixin. + * @param {Event} evt + */ + on_window_resize(evt) { + this.is_mobile = $(window).innerWidth() <= 768; // bs5 small/medium breakpoint + this.position = this.safe_position(this.position); + this.render(); + } + + /** + * Gets the current scroll position. + * @returns {number} + */ + get position() { + return this._position || 0; + } + + /** + * Sets the scroll position and triggers position update. + * @param {number} position + */ + set position(position) { + this._position = this.safe_position(position); + this.update(); + this.trigger('on_position', this._position); + } + + /** + * Gets the pointer events status. + * @returns {boolean} Whether pointer events are enabled. + */ + get pointer_events() { + return this.elem.css('pointer-events') === 'all'; + } + + /** + * Sets the pointer events status. + * @param {boolean} value Whether to enable pointer events. + */ + set pointer_events(value) { + this.elem.css('pointer-events', value ? 'all' : 'none'); + } + + /** + * Handles fading in and out of the scrollbar based on activity. + */ + fade_timer() { + if (!this.scrollbar.is(':visible')) { + this.scrollbar.fadeIn('fast'); + } + if (this.fade_out_timeout) { + clearTimeout(this.fade_out_timeout); + } + this.fade_out_timeout = setTimeout(() => { + this.scrollbar.fadeOut('slow'); + }, 700); + } + + /** + * Handles changes in the mobile state. + * @param {boolean} val + */ + on_is_mobile(val) { + if (val && this.contentsize > this.scrollsize) { + this.scrollbar.stop(true, true).show(); + this.elem.off('mouseenter mouseleave', this.on_hover); + } else { + this.scrollbar.stop(true, true).hide(); + this.elem.on('mouseenter mouseleave', this.on_hover); + } + } + + /** + * Binds events to the scrollbar. + */ + bind() { + this.pointer_events = true; + this.elem.on('mousewheel wheel', this.on_scroll); + this.scrollbar.on('click', this.on_click); + this.set_scope(this.thumb, $(document), this.elem); + } + + /** + * Unbinds events from the scrollbar. + */ + unbind() { + this.elem.off('mousewheel wheel', this.on_scroll); + this.elem.off('mouseenter mouseleave', this.on_hover); + this.scrollbar.off('click', this.on_click); + $(this.thumb).off('mousedown', this._down_handle); + } + + /** + * Destroys the scrollbar instance and cleans up. + */ + destroy() { + if (this.fade_out_timeout) { + clearTimeout(this.fade_out_timeout); + } + this.unbind(); + this.elem.removeData('scrollbar'); + } + + /** + * Compiles the scrollbar template. + */ + compile() { + ts.compile_template(this, ` +
+
+
+
+ `, this.elem); + } + + /** + * Renders the scrollbar and updates its dimensions. + * @param {string} [attr] Attribute to update ('width' or 'height'). + */ + render(attr) { + this.scrollbar.css(attr, this.scrollsize); + if (this.contentsize <= this.scrollsize) { + this.thumbsize = this.scrollsize; + } else { + this.thumbsize = Math.pow(this.scrollsize, 2) / this.contentsize; + } + this.thumb.css(attr, this.thumbsize); + this.update(); + // ensure correct scroll position when outside of safe bounds + this.position = this.safe_position(this.position); + } + + /** + * Validates and returns a safe scroll position. + * @param {number} position The desired scroll position. + * @returns {number} A safe scroll position within bounds. + * @throws Will throw an error if position is not a number. + */ + safe_position(position) { + if (typeof position !== 'number') { + throw new Error(`Scrollbar position must be a Number, position is: "${position}".`); + } + if (this.contentsize <= this.scrollsize) { + // reset position + return 0; + } + const max_pos = this.contentsize - this.scrollsize; + if (position >= max_pos) { + position = max_pos; + } else if (position <= 0) { + position = 0; + } + return position; + } + + /** + * Handles the state of the scrollbar when disabled. + * @param {boolean} value + */ + on_disabled(value) { + if (value) { + this.unbind(); + } else { + this.bind(); + } + } + + /** + * Handles hover events to show/hide the scrollbar. + * @param {Event} evt + */ + on_hover(evt) { + evt.preventDefault(); + evt.stopPropagation(); + const elem = this.elem; + if ( + (elem.has(evt.target).length > 0 || elem.is(evt.target)) && + this.contentsize > this.scrollsize + ) { + if (evt.type === 'mouseenter') { + this.scrollbar.stop(true, true).fadeIn(); + } else if ( + evt.type === 'mouseleave' && + evt.relatedTarget !== elem.get(0) + ) { + this.scrollbar.stop(true, true).fadeOut(); + } + } + } + + /** + * Handles scroll events to adjust the scrollbar position. + * @param {Event} evt + */ + on_scroll(evt) { + if (this.contentsize <= this.scrollsize) { + return; + } + let evt_ = evt.originalEvent; + if (typeof evt_.deltaY === 'number') { + if (evt_.deltaY > 0) { // down + this.position += this.scroll_step; + } else if (evt_.deltaY < 0) { // up + this.position -= this.scroll_step; + } + } + } + + /** + * Handles click events on the scrollbar. + * @param {Event} evt + */ + on_click(evt) { + evt.preventDefault(); // prevent text selection + this.thumb.addClass('active'); + let position = this.pos_from_evt(evt), + thumb_pos = position - this.offset - this.thumbsize / 2; + this.position = this.contentsize * thumb_pos / this.scrollsize; + this.thumb.removeClass('active'); + } + + /** + * Handles touch start events. + * @param {Event} evt + */ + touchstart(evt) { + const touch = evt.originalEvent.touches[0]; + this._touch_pos = this.pos_from_evt(touch); + this._start_position = this.position; + } + + /** + * Handles touch move events. + * @param {Event} evt + */ + touchmove(evt) { + if (this.contentsize <= this.scrollsize) { + return; + } + const touch = evt.originalEvent.touches[0]; + const delta = this.pos_from_evt(touch) - this._touch_pos; + this.position = this._start_position - delta; + this.fade_timer(); + } + + /** + * Handles touch end events. + * @param {Event} + */ + touchend(evt) { + delete this._touch_pos; + delete this._start_position; + } + + /** + * Handles mouse down events on the scrollbar thumb. + * @param {Event} evt + */ + down(evt) { + this._mouse_pos = this.pos_from_evt(evt) - this.offset; + this._thumb_pos = this.position / (this.contentsize / this.scrollsize); + this.elem.off('mouseenter mouseleave', this.on_hover); + this.thumb.addClass('active'); + } + + /** + * Handles mouse move events while dragging the scrollbar thumb. + * @param {Event} evt + */ + move(evt) { + let mouse_pos = this.pos_from_evt(evt) - this.offset, + thumb_pos = this._thumb_pos + mouse_pos - this._mouse_pos; + this.position = this.contentsize * thumb_pos / this.scrollsize; + } + + /** + * Handles mouse up events to finalize scrollbar movement. + * @param {Event} evt + */ + up(evt) { + delete this._mouse_pos; + delete this._thumb_pos; + this.elem.on('mouseenter mouseleave', this.on_hover); + this.thumb.removeClass('active'); + } +} + +/** + * Class representing a horizontal scrollbar. + * @extends Scrollbar + */ +export class ScrollbarX extends ResizeAware(Scrollbar) { + + /** + * Gets the horizontal offset of the scrollbar element. + * @returns {number} + */ + get offset() { + return this.elem.offset().left; + } + + /** + * Gets the total width of the content. + * @returns {number} + */ + get contentsize() { + return this.content.outerWidth(); + } + + /** + * Gets the scrollable container width. + * @returns {number} + */ + get scrollsize() { + const padding_r = parseFloat(this.elem.css('padding-right')); + const padding_l = parseFloat(this.elem.css('padding-left')); + return this.elem.outerWidth() - padding_l - padding_r; + } + + /** + * Compiles the scrollbar template and styles for horizontal scrollbar. + */ + compile() { + super.compile(); + this.thumb.css('height', '6px'); + this.scrollbar + .css('height', '6px') + .css('width', this.scrollsize); + this.thumbsize = this.scrollsize / (this.contentsize / this.scrollsize); + this.thumb.css('width', this.thumbsize); + } + + /** + * Renders the scrollbar and updates its width. + */ + render() { + super.render('width'); + } + + /** + * Updates the content position based on the current scrollbar position. + */ + update() { + let thumb_pos = this.position / (this.contentsize / this.scrollsize); + this.content.css('right', this.position + 'px'); + this.thumb.css('left', thumb_pos + 'px'); + } + + /** + * Gets the x-coordinate from the mouse event. + * @param {Event} e + * @returns {number} + */ + pos_from_evt(e) { + return e.pageX; + } +} + +/** + * Class representing a vertical scrollbar. + * @extends Scrollbar + */ +export class ScrollbarY extends ResizeAware(Scrollbar) { + + /** + * Gets the vertical offset of the scrollbar element. + * @returns {number} + */ + get offset() { + return this.elem.offset().top; + } + + /** + * Gets the total height of the content. + * @returns {number} + */ + get contentsize() { + return this.content.outerHeight(); + } + + /** + * Gets the scrollable container height. + * @returns {number} + */ + get scrollsize() { + const padding_t = parseFloat(this.elem.css('padding-top')); + const padding_b = parseFloat(this.elem.css('padding-bottom')); + return this.elem.outerHeight() - padding_t - padding_b; + } + + /** + * Compiles the scrollbar template and styles for vertical scrollbar. + */ + compile() { + super.compile(); + this.thumb.css('width', '6px'); + this.scrollbar + .css('width', '6px') + .css('top', '0px') + .css('height', this.scrollsize); + this.thumbsize = this.scrollsize / (this.contentsize / this.scrollsize); + this.thumb.css('height', this.thumbsize); + } + + /** + * Renders the scrollbar and updates its height. + */ + render() { + super.render('height'); + } + + /** + * Updates the content position based on the current scrollbar position. + */ + update() { + let thumb_pos = this.position / (this.contentsize / this.scrollsize); + this.content.css('bottom', this.position + 'px'); + this.thumb.css('top', thumb_pos + 'px'); + } + + /** + * Gets the y-coordinate from the mouse event. + * @param {Event} e + * @returns {number} + */ + pos_from_evt(e) { + return e.pageY; + } +} diff --git a/js/src/sidebar.js b/js/src/sidebar.js new file mode 100644 index 00000000..68539108 --- /dev/null +++ b/js/src/sidebar.js @@ -0,0 +1,177 @@ +import $ from 'jquery'; +import ts from 'treibstoff'; +import { global_events } from './globals.js'; +import { ResizeAware } from './layout.js'; + +/** + * Class to manage the sidebar of the application. + * @extends ts.Motion + */ +export class Sidebar extends ResizeAware(ts.Motion) { + + /** + * Initializes the Sidebar instance. + * @param {Element} context + */ + static initialize(context) { + const elem = ts.query_elem('#sidebar_left', context); + if (!elem) { + return; + } + new Sidebar(elem); + } + + /** + * Creates an instance of the Sidebar. + * @param {Element} elem + */ + constructor(elem) { + super(elem); + this.elem = elem; + elem.css('width', this.sidebar_width + 'px'); + + this.scrollbar = ts.query_elem('.scrollable-y', elem).data('scrollbar'); + + const scrollable_content = ts.query_elem('.scrollable-content', elem); + const pad_left = scrollable_content.css('padding-left'); + const pad_right = scrollable_content.css('padding-right'); + const logo_width = $('#header-logo').outerWidth(true); + elem.css( + 'min-width', + `calc(${logo_width}px + ${pad_left} + ${pad_right})` + ); + + this.on_click = this.on_click.bind(this); + this.collapse_elem = ts.query_elem('#sidebar_collapse', elem); + this.collapse_elem.on('click', this.on_click); + + const resizer_elem = ts.query_elem('#sidebar_resizer', elem); + this.set_scope(resizer_elem, $(document)); + + this.responsive_toggle = this.responsive_toggle.bind(this); + this.responsive_toggle(); + + // Enable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'auto'); + + ts.ajax.attach(this, elem); + } + + /** + * Gets the current width of the sidebar from local storage. + * @returns {number} + */ + get sidebar_width() { + return localStorage.getItem('cone-app-sidebar-width') || 300; + } + + /** + * Sets the width of the sidebar in local storage. + * @param {number} width + */ + set sidebar_width(width) { + localStorage.setItem('cone-app-sidebar-width', width); + } + + /** + * Checks if the sidebar is collapsed. + * @returns {boolean} + */ + get collapsed() { + return this.elem.outerWidth() <= 0; + } + + /** + * Handles sidebar responsive collapsed state. + * Invoked by ResizeAware mixin. + * @param {*} evt + */ + on_window_resize(evt) { + this.responsive_toggle(); + } + + /** + * Toggles the sidebar's responsive state and css class based on its width. + */ + responsive_toggle() { + if (this.collapsed) { + this.elem.removeClass('responsive-expanded'); + this.elem.addClass('responsive-collapsed'); + } else { + this.elem.addClass('responsive-expanded'); + this.elem.removeClass('responsive-collapsed'); + } + + if (this.collapsed !== this.responsive_collapsed) { + this.responsive_collapsed = this.collapsed; + global_events.trigger('on_sidebar_resize', this); + } + } + + /** + * Collapses the sidebar and triggers the resize event. + */ + collapse() { + // Enable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'auto'); + this.elem + .removeClass('expanded') + .addClass('collapsed'); + global_events.trigger('on_sidebar_resize', this); + } + + /** + * Expands the sidebar and triggers the resize event. + */ + expand() { + // Disable scroll to refresh page on mobile devices + $('html, body').css('overscroll-behavior', 'none'); + this.elem + .removeClass('collapsed') + .addClass('expanded'); + global_events.trigger('on_sidebar_resize', this); + } + + /** + * Handles click events to toggle the sidebar's collapsed state. + * @param {Event} evt + */ + on_click(evt) { + if (this.collapsed) { + this.expand(); + } else { + this.collapse(); + } + } + + /** + * Handles the mouse move event to adjust the sidebar width. + * @param {Event} evt + */ + move(evt) { + this.scrollbar.pointer_events = false; + if (evt.pageX <= 115) { + evt.pageX = 115; // Prevent sidebar from collapsing too much + } + this.sidebar_width = parseInt(evt.pageX); + this.elem.css('width', this.sidebar_width); + global_events.trigger('on_sidebar_resize', this); + } + + /** + * Finalizes the sidebar resizing on mouse up. + */ + up() { + this.scrollbar.pointer_events = true; + global_events.trigger('on_sidebar_resize', this); + } + + /* Destroy the sidebar and remove event listeners. */ + destroy() { + this.reset_state(); + $(window).off('resize', this.on_window_resize); + this.collapse_elem.off(); + this.scrollbar = null; + this.elem.off(); + } +} diff --git a/js/src/tabletoolbar.js b/js/src/tabletoolbar.js index 067e93b2..3bc4b5c7 100644 --- a/js/src/tabletoolbar.js +++ b/js/src/tabletoolbar.js @@ -1,7 +1,7 @@ import { BatchedItemsSize, BatchedItemsSearch -} from "./batcheditems.js"; +} from './batcheditems.js'; export class TableToolbar { diff --git a/js/src/translation.js b/js/src/translation.js index 55b06d7d..9ff2bdb4 100644 --- a/js/src/translation.js +++ b/js/src/translation.js @@ -1,6 +1,5 @@ import $ from 'jquery'; - export class Translation { static initialize(context) { @@ -10,24 +9,25 @@ export class Translation { } constructor(nav_elem) { + $('div.invalid-feedback', nav_elem.parent()).show(); this.nav_elem = nav_elem; this.fields_elem = nav_elem.next(); this.show_lang_handle = this.show_lang_handle.bind(this); $('li > a', nav_elem).on('click', this.show_lang_handle); if ($('li.error', nav_elem).length) { - $('li.error:first > a', nav_elem).click(); + $('li.error:first > a', nav_elem).trigger('click'); } else { - $('li.active > a', nav_elem).click(); + $('li > a.active', nav_elem).trigger('click'); } this.fields_elem.show(); } show_lang_handle(evt) { evt.preventDefault(); - this.nav_elem.children().removeClass('active'); + $('li > a', this.nav_elem).removeClass('active'); this.fields_elem.children().hide(); let elem = $(evt.currentTarget); - elem.parent().addClass('active'); + elem.addClass('active'); $(elem.attr('href'), this.fields_elem).show(); } } diff --git a/js/src/utils.js b/js/src/utils.js index 92042123..cd13c9c2 100644 --- a/js/src/utils.js +++ b/js/src/utils.js @@ -1,10 +1,25 @@ import ts from 'treibstoff'; +/** + * Creates a cookie with the specified name, value, and expiration days. + * This function is deprecated; use `ts.create_cookie` instead. + * + * @param {string} name + * @param {string} value + * @param {number} days + */ export function createCookie(name, value, days) { ts.deprecate('createCookie', 'ts.create_cookie', '1.1'); ts.create_cookie(name, value, days); } +/** + * Reads the value of the cookie with the specified name. + * This function is deprecated; use `ts.read_cookie` instead. + * + * @param {string} name + * @returns {string|null} + */ export function readCookie(name) { ts.deprecate('readCookie', 'ts.read_cookie', '1.1'); return ts.read_cookie(name); diff --git a/js/wtr.config.mjs b/js/wtr.config.mjs new file mode 100644 index 00000000..c648db3a --- /dev/null +++ b/js/wtr.config.mjs @@ -0,0 +1,28 @@ +import {importMapsPlugin} from '@web/dev-server-import-maps'; +import {defaultReporter, summaryReporter} from '@web/test-runner' + +export default { + nodeResolve: true, + testFramework: { + path: './node_modules/web-test-runner-qunit/dist/autorun.js', + config: { + noglobals: false + } + }, + files: [ + 'js/tests/**/test_*.js' + ], + reporters: [defaultReporter(), summaryReporter({flatten: true})], + plugins: [ + importMapsPlugin({ + inject: { + importMap: { + imports: { + 'jquery': './node_modules/jquery/src/jquery.js', + 'treibstoff': './sources/treibstoff/src/treibstoff.js', + }, + }, + }, + }), + ], +} diff --git a/mx.ini b/mx.ini index 5c57dfeb..5ee06c4d 100644 --- a/mx.ini +++ b/mx.ini @@ -8,7 +8,7 @@ version-overrides = main-package = -e .[test,docs] -mxmake-test-runner = zope-testrunner +mxmake-test-runner = pytest mxmake-templates = run-tests @@ -90,7 +90,7 @@ mxmake-source-path = src/yafowil [yafowil.bootstrap] url = ${settings:cs}/yafowil.bootstrap.git pushurl = ${settings:cs_push}/yafowil.bootstrap.git -branch = 2.0 +branch = bs5 mxmake-test-path = src mxmake-source-path = src/yafowil/bootstrap @@ -104,7 +104,7 @@ mxmake-source-path = src/yafowil/webob [yafowil.yaml] url = ${settings:cs}/yafowil.yaml.git pushurl = ${settings:cs_push}/yafowil.yaml.git -branch = master +branch = bs5 extras = test mxmake-test-path = src mxmake-source-path = src/yafowil/yaml diff --git a/package.json b/package.json index bf1f7553..e5fdc452 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,18 @@ { "name": "cone.app", - "version": "1.1.0-dev", + "version": "2.0.0-dev", "devDependencies": { "@rollup/plugin-terser": "^0.4.4", - "jquery": "git://github.com/jquery/jquery.git#2b6b5e0a3ba3029ec3ad1525a178920765e3adf1", - "karma": "^6.4.3", - "karma-chrome-launcher": "^3.2.0", - "karma-coverage": "^2.2.1", - "karma-module-resolver-preprocessor": "^1.1.3", - "karma-qunit": "^4.1.2", - "qunit": "^2.19.4", - "rollup": "^2.79.1", + "@web/dev-server-import-maps": "^0.2.1", + "@web/test-runner": "^0.18.3", + "@web/test-runner-core": "^0.13.4", + "install": "^0.13.0", + "jquery": "^4.0.0-beta.2", + "qunit": "^2.20.1", + "rollup": "^2.79.2", "rollup-plugin-cleanup": "^3.2.1", - "sass": "^1.77.4" - } + "sass": "^1.86.0", + "web-test-runner-qunit": "^2.0.0" + }, + "packageManager": "pnpm@9.3.0+sha512.ee7b93e0c2bd11409c6424f92b866f31d3ea1bef5fbe47d3c7500cdc3c9668833d2e55681ad66df5b640c61fa9dc25d546efa54d76d7f8bf54b13614ac293631" } diff --git a/scss/README.md b/scss/README.md new file mode 100644 index 00000000..82f1b491 --- /dev/null +++ b/scss/README.md @@ -0,0 +1,9 @@ + + + + + + +$border-radius: .25rem (default .375rem); +$primary: #4171b6 (default $blue); +$secondary: #4e2889 (default $gray-600); diff --git a/scss/bootstrap/_accordion.scss b/scss/bootstrap/_accordion.scss new file mode 100644 index 00000000..17e5436e --- /dev/null +++ b/scss/bootstrap/_accordion.scss @@ -0,0 +1,158 @@ +// +// Base styles +// + +.accordion { + // scss-docs-start accordion-css-vars + --#{$prefix}accordion-color: #{$accordion-color}; + --#{$prefix}accordion-bg: #{$accordion-bg}; + --#{$prefix}accordion-transition: #{$accordion-transition}; + --#{$prefix}accordion-border-color: #{$accordion-border-color}; + --#{$prefix}accordion-border-width: #{$accordion-border-width}; + --#{$prefix}accordion-border-radius: #{$accordion-border-radius}; + --#{$prefix}accordion-inner-border-radius: #{$accordion-inner-border-radius}; + --#{$prefix}accordion-btn-padding-x: #{$accordion-button-padding-x}; + --#{$prefix}accordion-btn-padding-y: #{$accordion-button-padding-y}; + --#{$prefix}accordion-btn-color: #{$accordion-button-color}; + --#{$prefix}accordion-btn-bg: #{$accordion-button-bg}; + --#{$prefix}accordion-btn-icon: #{escape-svg($accordion-button-icon)}; + --#{$prefix}accordion-btn-icon-width: #{$accordion-icon-width}; + --#{$prefix}accordion-btn-icon-transform: #{$accordion-icon-transform}; + --#{$prefix}accordion-btn-icon-transition: #{$accordion-icon-transition}; + --#{$prefix}accordion-btn-active-icon: #{escape-svg($accordion-button-active-icon)}; + --#{$prefix}accordion-btn-focus-box-shadow: #{$accordion-button-focus-box-shadow}; + --#{$prefix}accordion-body-padding-x: #{$accordion-body-padding-x}; + --#{$prefix}accordion-body-padding-y: #{$accordion-body-padding-y}; + --#{$prefix}accordion-active-color: #{$accordion-button-active-color}; + --#{$prefix}accordion-active-bg: #{$accordion-button-active-bg}; + // scss-docs-end accordion-css-vars +} + +.accordion-button { + position: relative; + display: flex; + align-items: center; + width: 100%; + padding: var(--#{$prefix}accordion-btn-padding-y) var(--#{$prefix}accordion-btn-padding-x); + @include font-size($font-size-base); + color: var(--#{$prefix}accordion-btn-color); + text-align: left; // Reset button style + background-color: var(--#{$prefix}accordion-btn-bg); + border: 0; + @include border-radius(0); + overflow-anchor: none; + @include transition(var(--#{$prefix}accordion-transition)); + + &:not(.collapsed) { + color: var(--#{$prefix}accordion-active-color); + background-color: var(--#{$prefix}accordion-active-bg); + box-shadow: inset 0 calc(-1 * var(--#{$prefix}accordion-border-width)) 0 var(--#{$prefix}accordion-border-color); // stylelint-disable-line function-disallowed-list + + &::after { + background-image: var(--#{$prefix}accordion-btn-active-icon); + transform: var(--#{$prefix}accordion-btn-icon-transform); + } + } + + // Accordion icon + &::after { + flex-shrink: 0; + width: var(--#{$prefix}accordion-btn-icon-width); + height: var(--#{$prefix}accordion-btn-icon-width); + margin-left: auto; + content: ""; + background-image: var(--#{$prefix}accordion-btn-icon); + background-repeat: no-repeat; + background-size: var(--#{$prefix}accordion-btn-icon-width); + @include transition(var(--#{$prefix}accordion-btn-icon-transition)); + } + + &:hover { + z-index: 2; + } + + &:focus { + z-index: 3; + outline: 0; + box-shadow: var(--#{$prefix}accordion-btn-focus-box-shadow); + } +} + +.accordion-header { + margin-bottom: 0; +} + +.accordion-item { + color: var(--#{$prefix}accordion-color); + background-color: var(--#{$prefix}accordion-bg); + border: var(--#{$prefix}accordion-border-width) solid var(--#{$prefix}accordion-border-color); + + &:first-of-type { + @include border-top-radius(var(--#{$prefix}accordion-border-radius)); + + > .accordion-header .accordion-button { + @include border-top-radius(var(--#{$prefix}accordion-inner-border-radius)); + } + } + + &:not(:first-of-type) { + border-top: 0; + } + + // Only set a border-radius on the last item if the accordion is collapsed + &:last-of-type { + @include border-bottom-radius(var(--#{$prefix}accordion-border-radius)); + + > .accordion-header .accordion-button { + &.collapsed { + @include border-bottom-radius(var(--#{$prefix}accordion-inner-border-radius)); + } + } + + > .accordion-collapse { + @include border-bottom-radius(var(--#{$prefix}accordion-border-radius)); + } + } +} + +.accordion-body { + padding: var(--#{$prefix}accordion-body-padding-y) var(--#{$prefix}accordion-body-padding-x); +} + + +// Flush accordion items +// +// Remove borders and border-radius to keep accordion items edge-to-edge. + +.accordion-flush { + > .accordion-item { + border-right: 0; + border-left: 0; + @include border-radius(0); + + &:first-child { border-top: 0; } + &:last-child { border-bottom: 0; } + + // stylelint-disable selector-max-class + > .accordion-header .accordion-button { + &, + &.collapsed { + @include border-radius(0); + } + } + // stylelint-enable selector-max-class + + > .accordion-collapse { + @include border-radius(0); + } + } +} + +@if $enable-dark-mode { + @include color-mode(dark) { + .accordion-button::after { + --#{$prefix}accordion-btn-icon: #{escape-svg($accordion-button-icon-dark)}; + --#{$prefix}accordion-btn-active-icon: #{escape-svg($accordion-button-active-icon-dark)}; + } + } +} diff --git a/scss/bootstrap/_alert.scss b/scss/bootstrap/_alert.scss new file mode 100644 index 00000000..b8cff9b7 --- /dev/null +++ b/scss/bootstrap/_alert.scss @@ -0,0 +1,68 @@ +// +// Base styles +// + +.alert { + // scss-docs-start alert-css-vars + --#{$prefix}alert-bg: transparent; + --#{$prefix}alert-padding-x: #{$alert-padding-x}; + --#{$prefix}alert-padding-y: #{$alert-padding-y}; + --#{$prefix}alert-margin-bottom: #{$alert-margin-bottom}; + --#{$prefix}alert-color: inherit; + --#{$prefix}alert-border-color: transparent; + --#{$prefix}alert-border: #{$alert-border-width} solid var(--#{$prefix}alert-border-color); + --#{$prefix}alert-border-radius: #{$alert-border-radius}; + --#{$prefix}alert-link-color: inherit; + // scss-docs-end alert-css-vars + + position: relative; + padding: var(--#{$prefix}alert-padding-y) var(--#{$prefix}alert-padding-x); + margin-bottom: var(--#{$prefix}alert-margin-bottom); + color: var(--#{$prefix}alert-color); + background-color: var(--#{$prefix}alert-bg); + border: var(--#{$prefix}alert-border); + @include border-radius(var(--#{$prefix}alert-border-radius)); +} + +// Headings for larger alerts +.alert-heading { + // Specified to prevent conflicts of changing $headings-color + color: inherit; +} + +// Provide class for links that match alerts +.alert-link { + font-weight: $alert-link-font-weight; + color: var(--#{$prefix}alert-link-color); +} + + +// Dismissible alerts +// +// Expand the right padding and account for the close button's positioning. + +.alert-dismissible { + padding-right: $alert-dismissible-padding-r; + + // Adjust close link position + .btn-close { + position: absolute; + top: 0; + right: 0; + z-index: $stretched-link-z-index + 1; + padding: $alert-padding-y * 1.25 $alert-padding-x; + } +} + + +// scss-docs-start alert-modifiers +// Generate contextual modifier classes for colorizing the alert +@each $state in map-keys($theme-colors) { + .alert-#{$state} { + --#{$prefix}alert-color: var(--#{$prefix}#{$state}-text-emphasis); + --#{$prefix}alert-bg: var(--#{$prefix}#{$state}-bg-subtle); + --#{$prefix}alert-border-color: var(--#{$prefix}#{$state}-border-subtle); + --#{$prefix}alert-link-color: var(--#{$prefix}#{$state}-text-emphasis); + } +} +// scss-docs-end alert-modifiers diff --git a/scss/bootstrap/_badge.scss b/scss/bootstrap/_badge.scss new file mode 100644 index 00000000..cc3d2695 --- /dev/null +++ b/scss/bootstrap/_badge.scss @@ -0,0 +1,38 @@ +// Base class +// +// Requires one of the contextual, color modifier classes for `color` and +// `background-color`. + +.badge { + // scss-docs-start badge-css-vars + --#{$prefix}badge-padding-x: #{$badge-padding-x}; + --#{$prefix}badge-padding-y: #{$badge-padding-y}; + @include rfs($badge-font-size, --#{$prefix}badge-font-size); + --#{$prefix}badge-font-weight: #{$badge-font-weight}; + --#{$prefix}badge-color: #{$badge-color}; + --#{$prefix}badge-border-radius: #{$badge-border-radius}; + // scss-docs-end badge-css-vars + + display: inline-block; + padding: var(--#{$prefix}badge-padding-y) var(--#{$prefix}badge-padding-x); + @include font-size(var(--#{$prefix}badge-font-size)); + font-weight: var(--#{$prefix}badge-font-weight); + line-height: 1; + color: var(--#{$prefix}badge-color); + text-align: center; + white-space: nowrap; + vertical-align: baseline; + @include border-radius(var(--#{$prefix}badge-border-radius)); + @include gradient-bg(); + + // Empty badges collapse automatically + &:empty { + display: none; + } +} + +// Quick fix for badges in buttons +.btn .badge { + position: relative; + top: -1px; +} diff --git a/scss/bootstrap/_breadcrumb.scss b/scss/bootstrap/_breadcrumb.scss new file mode 100644 index 00000000..b8252ff2 --- /dev/null +++ b/scss/bootstrap/_breadcrumb.scss @@ -0,0 +1,40 @@ +.breadcrumb { + // scss-docs-start breadcrumb-css-vars + --#{$prefix}breadcrumb-padding-x: #{$breadcrumb-padding-x}; + --#{$prefix}breadcrumb-padding-y: #{$breadcrumb-padding-y}; + --#{$prefix}breadcrumb-margin-bottom: #{$breadcrumb-margin-bottom}; + @include rfs($breadcrumb-font-size, --#{$prefix}breadcrumb-font-size); + --#{$prefix}breadcrumb-bg: #{$breadcrumb-bg}; + --#{$prefix}breadcrumb-border-radius: #{$breadcrumb-border-radius}; + --#{$prefix}breadcrumb-divider-color: #{$breadcrumb-divider-color}; + --#{$prefix}breadcrumb-item-padding-x: #{$breadcrumb-item-padding-x}; + --#{$prefix}breadcrumb-item-active-color: #{$breadcrumb-active-color}; + // scss-docs-end breadcrumb-css-vars + + display: flex; + flex-wrap: wrap; + padding: var(--#{$prefix}breadcrumb-padding-y) var(--#{$prefix}breadcrumb-padding-x); + margin-bottom: var(--#{$prefix}breadcrumb-margin-bottom); + @include font-size(var(--#{$prefix}breadcrumb-font-size)); + list-style: none; + background-color: var(--#{$prefix}breadcrumb-bg); + @include border-radius(var(--#{$prefix}breadcrumb-border-radius)); +} + +.breadcrumb-item { + // The separator between breadcrumbs (by default, a forward-slash: "/") + + .breadcrumb-item { + padding-left: var(--#{$prefix}breadcrumb-item-padding-x); + + &::before { + float: left; // Suppress inline spacings and underlining of the separator + padding-right: var(--#{$prefix}breadcrumb-item-padding-x); + color: var(--#{$prefix}breadcrumb-divider-color); + content: var(--#{$prefix}breadcrumb-divider, escape-svg($breadcrumb-divider)) #{"/* rtl:"} var(--#{$prefix}breadcrumb-divider, escape-svg($breadcrumb-divider-flipped)) #{"*/"}; + } + } + + &.active { + color: var(--#{$prefix}breadcrumb-item-active-color); + } +} diff --git a/scss/bootstrap/_button-group.scss b/scss/bootstrap/_button-group.scss new file mode 100644 index 00000000..55ae3f65 --- /dev/null +++ b/scss/bootstrap/_button-group.scss @@ -0,0 +1,142 @@ +// Make the div behave like a button +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-flex; + vertical-align: middle; // match .btn alignment given font-size hack above + + > .btn { + position: relative; + flex: 1 1 auto; + } + + // Bring the hover, focused, and "active" buttons to the front to overlay + // the borders properly + > .btn-check:checked + .btn, + > .btn-check:focus + .btn, + > .btn:hover, + > .btn:focus, + > .btn:active, + > .btn.active { + z-index: 1; + } +} + +// Optional: Group multiple button groups together for a toolbar +.btn-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + + .input-group { + width: auto; + } +} + +.btn-group { + @include border-radius($btn-border-radius); + + // Prevent double borders when buttons are next to each other + > :not(.btn-check:first-child) + .btn, + > .btn-group:not(:first-child) { + margin-left: calc(#{$btn-border-width} * -1); // stylelint-disable-line function-disallowed-list + } + + // Reset rounded corners + > .btn:not(:last-child):not(.dropdown-toggle), + > .btn.dropdown-toggle-split:first-child, + > .btn-group:not(:last-child) > .btn { + @include border-end-radius(0); + } + + // The left radius should be 0 if the button is: + // - the "third or more" child + // - the second child and the previous element isn't `.btn-check` (making it the first child visually) + // - part of a btn-group which isn't the first child + > .btn:nth-child(n + 3), + > :not(.btn-check) + .btn, + > .btn-group:not(:first-child) > .btn { + @include border-start-radius(0); + } +} + +// Sizing +// +// Remix the default button sizing classes into new ones for easier manipulation. + +.btn-group-sm > .btn { @extend .btn-sm; } +.btn-group-lg > .btn { @extend .btn-lg; } + + +// +// Split button dropdowns +// + +.dropdown-toggle-split { + padding-right: $btn-padding-x * .75; + padding-left: $btn-padding-x * .75; + + &::after, + .dropup &::after, + .dropend &::after { + margin-left: 0; + } + + .dropstart &::before { + margin-right: 0; + } +} + +.btn-sm + .dropdown-toggle-split { + padding-right: $btn-padding-x-sm * .75; + padding-left: $btn-padding-x-sm * .75; +} + +.btn-lg + .dropdown-toggle-split { + padding-right: $btn-padding-x-lg * .75; + padding-left: $btn-padding-x-lg * .75; +} + + +// The clickable button for toggling the menu +// Set the same inset shadow as the :active state +.btn-group.show .dropdown-toggle { + @include box-shadow($btn-active-box-shadow); + + // Show no shadow for `.btn-link` since it has no other button styles. + &.btn-link { + @include box-shadow(none); + } +} + + +// +// Vertical button groups +// + +.btn-group-vertical { + flex-direction: column; + align-items: flex-start; + justify-content: center; + + > .btn, + > .btn-group { + width: 100%; + } + + > .btn:not(:first-child), + > .btn-group:not(:first-child) { + margin-top: calc(#{$btn-border-width} * -1); // stylelint-disable-line function-disallowed-list + } + + // Reset rounded corners + > .btn:not(:last-child):not(.dropdown-toggle), + > .btn-group:not(:last-child) > .btn { + @include border-bottom-radius(0); + } + + > .btn ~ .btn, + > .btn-group:not(:first-child) > .btn { + @include border-top-radius(0); + } +} diff --git a/scss/bootstrap/_buttons.scss b/scss/bootstrap/_buttons.scss new file mode 100644 index 00000000..caa4518a --- /dev/null +++ b/scss/bootstrap/_buttons.scss @@ -0,0 +1,216 @@ +// +// Base styles +// + +.btn { + // scss-docs-start btn-css-vars + --#{$prefix}btn-padding-x: #{$btn-padding-x}; + --#{$prefix}btn-padding-y: #{$btn-padding-y}; + --#{$prefix}btn-font-family: #{$btn-font-family}; + @include rfs($btn-font-size, --#{$prefix}btn-font-size); + --#{$prefix}btn-font-weight: #{$btn-font-weight}; + --#{$prefix}btn-line-height: #{$btn-line-height}; + --#{$prefix}btn-color: #{$btn-color}; + --#{$prefix}btn-bg: transparent; + --#{$prefix}btn-border-width: #{$btn-border-width}; + --#{$prefix}btn-border-color: transparent; + --#{$prefix}btn-border-radius: #{$btn-border-radius}; + --#{$prefix}btn-hover-border-color: transparent; + --#{$prefix}btn-box-shadow: #{$btn-box-shadow}; + --#{$prefix}btn-disabled-opacity: #{$btn-disabled-opacity}; + --#{$prefix}btn-focus-box-shadow: 0 0 0 #{$btn-focus-width} rgba(var(--#{$prefix}btn-focus-shadow-rgb), .5); + // scss-docs-end btn-css-vars + + display: inline-block; + padding: var(--#{$prefix}btn-padding-y) var(--#{$prefix}btn-padding-x); + font-family: var(--#{$prefix}btn-font-family); + @include font-size(var(--#{$prefix}btn-font-size)); + font-weight: var(--#{$prefix}btn-font-weight); + line-height: var(--#{$prefix}btn-line-height); + color: var(--#{$prefix}btn-color); + text-align: center; + text-decoration: if($link-decoration == none, null, none); + white-space: $btn-white-space; + vertical-align: middle; + cursor: if($enable-button-pointers, pointer, null); + user-select: none; + border: var(--#{$prefix}btn-border-width) solid var(--#{$prefix}btn-border-color); + @include border-radius(var(--#{$prefix}btn-border-radius)); + @include gradient-bg(var(--#{$prefix}btn-bg)); + @include box-shadow(var(--#{$prefix}btn-box-shadow)); + @include transition($btn-transition); + + &:hover { + color: var(--#{$prefix}btn-hover-color); + text-decoration: if($link-hover-decoration == underline, none, null); + background-color: var(--#{$prefix}btn-hover-bg); + border-color: var(--#{$prefix}btn-hover-border-color); + } + + .btn-check + &:hover { + // override for the checkbox/radio buttons + color: var(--#{$prefix}btn-color); + background-color: var(--#{$prefix}btn-bg); + border-color: var(--#{$prefix}btn-border-color); + } + + &:focus-visible { + color: var(--#{$prefix}btn-hover-color); + @include gradient-bg(var(--#{$prefix}btn-hover-bg)); + border-color: var(--#{$prefix}btn-hover-border-color); + outline: 0; + // Avoid using mixin so we can pass custom focus shadow properly + @if $enable-shadows { + box-shadow: var(--#{$prefix}btn-box-shadow), var(--#{$prefix}btn-focus-box-shadow); + } @else { + box-shadow: var(--#{$prefix}btn-focus-box-shadow); + } + } + + .btn-check:focus-visible + & { + border-color: var(--#{$prefix}btn-hover-border-color); + outline: 0; + // Avoid using mixin so we can pass custom focus shadow properly + @if $enable-shadows { + box-shadow: var(--#{$prefix}btn-box-shadow), var(--#{$prefix}btn-focus-box-shadow); + } @else { + box-shadow: var(--#{$prefix}btn-focus-box-shadow); + } + } + + .btn-check:checked + &, + :not(.btn-check) + &:active, + &:first-child:active, + &.active, + &.show { + color: var(--#{$prefix}btn-active-color); + background-color: var(--#{$prefix}btn-active-bg); + // Remove CSS gradients if they're enabled + background-image: if($enable-gradients, none, null); + border-color: var(--#{$prefix}btn-active-border-color); + @include box-shadow(var(--#{$prefix}btn-active-shadow)); + + &:focus-visible { + // Avoid using mixin so we can pass custom focus shadow properly + @if $enable-shadows { + box-shadow: var(--#{$prefix}btn-active-shadow), var(--#{$prefix}btn-focus-box-shadow); + } @else { + box-shadow: var(--#{$prefix}btn-focus-box-shadow); + } + } + } + + .btn-check:checked:focus-visible + & { + // Avoid using mixin so we can pass custom focus shadow properly + @if $enable-shadows { + box-shadow: var(--#{$prefix}btn-active-shadow), var(--#{$prefix}btn-focus-box-shadow); + } @else { + box-shadow: var(--#{$prefix}btn-focus-box-shadow); + } + } + + &:disabled, + &.disabled, + fieldset:disabled & { + color: var(--#{$prefix}btn-disabled-color); + pointer-events: none; + background-color: var(--#{$prefix}btn-disabled-bg); + background-image: if($enable-gradients, none, null); + border-color: var(--#{$prefix}btn-disabled-border-color); + opacity: var(--#{$prefix}btn-disabled-opacity); + @include box-shadow(none); + } +} + + +// +// Alternate buttons +// + +// scss-docs-start btn-variant-loops +@each $color, $value in $theme-colors { + .btn-#{$color} { + @if $color == "light" { + @include button-variant( + $value, + $value, + $hover-background: shade-color($value, $btn-hover-bg-shade-amount), + $hover-border: shade-color($value, $btn-hover-border-shade-amount), + $active-background: shade-color($value, $btn-active-bg-shade-amount), + $active-border: shade-color($value, $btn-active-border-shade-amount) + ); + } @else if $color == "dark" { + @include button-variant( + $value, + $value, + $hover-background: tint-color($value, $btn-hover-bg-tint-amount), + $hover-border: tint-color($value, $btn-hover-border-tint-amount), + $active-background: tint-color($value, $btn-active-bg-tint-amount), + $active-border: tint-color($value, $btn-active-border-tint-amount) + ); + } @else { + @include button-variant($value, $value); + } + } +} + +@each $color, $value in $theme-colors { + .btn-outline-#{$color} { + @include button-outline-variant($value); + } +} +// scss-docs-end btn-variant-loops + + +// +// Link buttons +// + +// Make a button look and behave like a link +.btn-link { + --#{$prefix}btn-font-weight: #{$font-weight-normal}; + --#{$prefix}btn-color: #{$btn-link-color}; + --#{$prefix}btn-bg: transparent; + --#{$prefix}btn-border-color: transparent; + --#{$prefix}btn-hover-color: #{$btn-link-hover-color}; + --#{$prefix}btn-hover-border-color: transparent; + --#{$prefix}btn-active-color: #{$btn-link-hover-color}; + --#{$prefix}btn-active-border-color: transparent; + --#{$prefix}btn-disabled-color: #{$btn-link-disabled-color}; + --#{$prefix}btn-disabled-border-color: transparent; + --#{$prefix}btn-box-shadow: 0 0 0 #000; // Can't use `none` as keyword negates all values when used with multiple shadows + --#{$prefix}btn-focus-shadow-rgb: #{$btn-link-focus-shadow-rgb}; + + text-decoration: $link-decoration; + @if $enable-gradients { + background-image: none; + } + + &:hover, + &:focus-visible { + text-decoration: $link-hover-decoration; + } + + &:focus-visible { + color: var(--#{$prefix}btn-color); + } + + &:hover { + color: var(--#{$prefix}btn-hover-color); + } + + // No need for an active state here +} + + +// +// Button Sizes +// + +.btn-lg { + @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-border-radius-lg); +} + +.btn-sm { + @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $btn-font-size-sm, $btn-border-radius-sm); +} diff --git a/scss/bootstrap/_card.scss b/scss/bootstrap/_card.scss new file mode 100644 index 00000000..d3535a98 --- /dev/null +++ b/scss/bootstrap/_card.scss @@ -0,0 +1,239 @@ +// +// Base styles +// + +.card { + // scss-docs-start card-css-vars + --#{$prefix}card-spacer-y: #{$card-spacer-y}; + --#{$prefix}card-spacer-x: #{$card-spacer-x}; + --#{$prefix}card-title-spacer-y: #{$card-title-spacer-y}; + --#{$prefix}card-title-color: #{$card-title-color}; + --#{$prefix}card-subtitle-color: #{$card-subtitle-color}; + --#{$prefix}card-border-width: #{$card-border-width}; + --#{$prefix}card-border-color: #{$card-border-color}; + --#{$prefix}card-border-radius: #{$card-border-radius}; + --#{$prefix}card-box-shadow: #{$card-box-shadow}; + --#{$prefix}card-inner-border-radius: #{$card-inner-border-radius}; + --#{$prefix}card-cap-padding-y: #{$card-cap-padding-y}; + --#{$prefix}card-cap-padding-x: #{$card-cap-padding-x}; + --#{$prefix}card-cap-bg: #{$card-cap-bg}; + --#{$prefix}card-cap-color: #{$card-cap-color}; + --#{$prefix}card-height: #{$card-height}; + --#{$prefix}card-color: #{$card-color}; + --#{$prefix}card-bg: #{$card-bg}; + --#{$prefix}card-img-overlay-padding: #{$card-img-overlay-padding}; + --#{$prefix}card-group-margin: #{$card-group-margin}; + // scss-docs-end card-css-vars + + position: relative; + display: flex; + flex-direction: column; + min-width: 0; // See https://github.com/twbs/bootstrap/pull/22740#issuecomment-305868106 + height: var(--#{$prefix}card-height); + color: var(--#{$prefix}body-color); + word-wrap: break-word; + background-color: var(--#{$prefix}card-bg); + background-clip: border-box; + border: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color); + @include border-radius(var(--#{$prefix}card-border-radius)); + @include box-shadow(var(--#{$prefix}card-box-shadow)); + + > hr { + margin-right: 0; + margin-left: 0; + } + + > .list-group { + border-top: inherit; + border-bottom: inherit; + + &:first-child { + border-top-width: 0; + @include border-top-radius(var(--#{$prefix}card-inner-border-radius)); + } + + &:last-child { + border-bottom-width: 0; + @include border-bottom-radius(var(--#{$prefix}card-inner-border-radius)); + } + } + + // Due to specificity of the above selector (`.card > .list-group`), we must + // use a child selector here to prevent double borders. + > .card-header + .list-group, + > .list-group + .card-footer { + border-top: 0; + } +} + +.card-body { + // Enable `flex-grow: 1` for decks and groups so that card blocks take up + // as much space as possible, ensuring footers are aligned to the bottom. + flex: 1 1 auto; + padding: var(--#{$prefix}card-spacer-y) var(--#{$prefix}card-spacer-x); + color: var(--#{$prefix}card-color); +} + +.card-title { + margin-bottom: var(--#{$prefix}card-title-spacer-y); + color: var(--#{$prefix}card-title-color); +} + +.card-subtitle { + margin-top: calc(-.5 * var(--#{$prefix}card-title-spacer-y)); // stylelint-disable-line function-disallowed-list + margin-bottom: 0; + color: var(--#{$prefix}card-subtitle-color); +} + +.card-text:last-child { + margin-bottom: 0; +} + +.card-link { + &:hover { + text-decoration: if($link-hover-decoration == underline, none, null); + } + + + .card-link { + margin-left: var(--#{$prefix}card-spacer-x); + } +} + +// +// Optional textual caps +// + +.card-header { + padding: var(--#{$prefix}card-cap-padding-y) var(--#{$prefix}card-cap-padding-x); + margin-bottom: 0; // Removes the default margin-bottom of + color: var(--#{$prefix}card-cap-color); + background-color: var(--#{$prefix}card-cap-bg); + border-bottom: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color); + + &:first-child { + @include border-radius(var(--#{$prefix}card-inner-border-radius) var(--#{$prefix}card-inner-border-radius) 0 0); + } +} + +.card-footer { + padding: var(--#{$prefix}card-cap-padding-y) var(--#{$prefix}card-cap-padding-x); + color: var(--#{$prefix}card-cap-color); + background-color: var(--#{$prefix}card-cap-bg); + border-top: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color); + + &:last-child { + @include border-radius(0 0 var(--#{$prefix}card-inner-border-radius) var(--#{$prefix}card-inner-border-radius)); + } +} + + +// +// Header navs +// + +.card-header-tabs { + margin-right: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list + margin-bottom: calc(-1 * var(--#{$prefix}card-cap-padding-y)); // stylelint-disable-line function-disallowed-list + margin-left: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list + border-bottom: 0; + + .nav-link.active { + background-color: var(--#{$prefix}card-bg); + border-bottom-color: var(--#{$prefix}card-bg); + } +} + +.card-header-pills { + margin-right: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list + margin-left: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list +} + +// Card image +.card-img-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: var(--#{$prefix}card-img-overlay-padding); + @include border-radius(var(--#{$prefix}card-inner-border-radius)); +} + +.card-img, +.card-img-top, +.card-img-bottom { + width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch +} + +.card-img, +.card-img-top { + @include border-top-radius(var(--#{$prefix}card-inner-border-radius)); +} + +.card-img, +.card-img-bottom { + @include border-bottom-radius(var(--#{$prefix}card-inner-border-radius)); +} + + +// +// Card groups +// + +.card-group { + // The child selector allows nested `.card` within `.card-group` + // to display properly. + > .card { + margin-bottom: var(--#{$prefix}card-group-margin); + } + + @include media-breakpoint-up(sm) { + display: flex; + flex-flow: row wrap; + // The child selector allows nested `.card` within `.card-group` + // to display properly. + > .card { + // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4 + flex: 1 0 0%; + margin-bottom: 0; + + + .card { + margin-left: 0; + border-left: 0; + } + + // Handle rounded corners + @if $enable-rounded { + &:not(:last-child) { + @include border-end-radius(0); + + .card-img-top, + .card-header { + // stylelint-disable-next-line property-disallowed-list + border-top-right-radius: 0; + } + .card-img-bottom, + .card-footer { + // stylelint-disable-next-line property-disallowed-list + border-bottom-right-radius: 0; + } + } + + &:not(:first-child) { + @include border-start-radius(0); + + .card-img-top, + .card-header { + // stylelint-disable-next-line property-disallowed-list + border-top-left-radius: 0; + } + .card-img-bottom, + .card-footer { + // stylelint-disable-next-line property-disallowed-list + border-bottom-left-radius: 0; + } + } + } + } + } +} diff --git a/scss/bootstrap/_carousel.scss b/scss/bootstrap/_carousel.scss new file mode 100644 index 00000000..3a135220 --- /dev/null +++ b/scss/bootstrap/_carousel.scss @@ -0,0 +1,236 @@ +// Notes on the classes: +// +// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically) +// even when their scroll action started on a carousel, but for compatibility (with Firefox) +// we're preventing all actions instead +// 2. The .carousel-item-start and .carousel-item-end is used to indicate where +// the active slide is heading. +// 3. .active.carousel-item is the current slide. +// 4. .active.carousel-item-start and .active.carousel-item-end is the current +// slide in its in-transition state. Only one of these occurs at a time. +// 5. .carousel-item-next.carousel-item-start and .carousel-item-prev.carousel-item-end +// is the upcoming slide in transition. + +.carousel { + position: relative; +} + +.carousel.pointer-event { + touch-action: pan-y; +} + +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; + @include clearfix(); +} + +.carousel-item { + position: relative; + display: none; + float: left; + width: 100%; + margin-right: -100%; + backface-visibility: hidden; + @include transition($carousel-transition); +} + +.carousel-item.active, +.carousel-item-next, +.carousel-item-prev { + display: block; +} + +.carousel-item-next:not(.carousel-item-start), +.active.carousel-item-end { + transform: translateX(100%); +} + +.carousel-item-prev:not(.carousel-item-end), +.active.carousel-item-start { + transform: translateX(-100%); +} + + +// +// Alternate transitions +// + +.carousel-fade { + .carousel-item { + opacity: 0; + transition-property: opacity; + transform: none; + } + + .carousel-item.active, + .carousel-item-next.carousel-item-start, + .carousel-item-prev.carousel-item-end { + z-index: 1; + opacity: 1; + } + + .active.carousel-item-start, + .active.carousel-item-end { + z-index: 0; + opacity: 0; + @include transition(opacity 0s $carousel-transition-duration); + } +} + + +// +// Left/right controls for nav +// + +.carousel-control-prev, +.carousel-control-next { + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + // Use flex for alignment (1-3) + display: flex; // 1. allow flex styles + align-items: center; // 2. vertically center contents + justify-content: center; // 3. horizontally center contents + width: $carousel-control-width; + padding: 0; + color: $carousel-control-color; + text-align: center; + background: none; + border: 0; + opacity: $carousel-control-opacity; + @include transition($carousel-control-transition); + + // Hover/focus state + &:hover, + &:focus { + color: $carousel-control-color; + text-decoration: none; + outline: 0; + opacity: $carousel-control-hover-opacity; + } +} +.carousel-control-prev { + left: 0; + background-image: if($enable-gradients, linear-gradient(90deg, rgba($black, .25), rgba($black, .001)), null); +} +.carousel-control-next { + right: 0; + background-image: if($enable-gradients, linear-gradient(270deg, rgba($black, .25), rgba($black, .001)), null); +} + +// Icons for within +.carousel-control-prev-icon, +.carousel-control-next-icon { + display: inline-block; + width: $carousel-control-icon-width; + height: $carousel-control-icon-width; + background-repeat: no-repeat; + background-position: 50%; + background-size: 100% 100%; +} + +.carousel-control-prev-icon { + background-image: escape-svg($carousel-control-prev-icon-bg) #{"/*rtl:" + escape-svg($carousel-control-next-icon-bg) + "*/"}; +} +.carousel-control-next-icon { + background-image: escape-svg($carousel-control-next-icon-bg) #{"/*rtl:" + escape-svg($carousel-control-prev-icon-bg) + "*/"}; +} + +// Optional indicator pips/controls +// +// Add a container (such as a list) with the following class and add an item (ideally a focusable control, +// like a button) with data-bs-target for each slide your carousel holds. + +.carousel-indicators { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + display: flex; + justify-content: center; + padding: 0; + // Use the .carousel-control's width as margin so we don't overlay those + margin-right: $carousel-control-width; + margin-bottom: 1rem; + margin-left: $carousel-control-width; + + [data-bs-target] { + box-sizing: content-box; + flex: 0 1 auto; + width: $carousel-indicator-width; + height: $carousel-indicator-height; + padding: 0; + margin-right: $carousel-indicator-spacer; + margin-left: $carousel-indicator-spacer; + text-indent: -999px; + cursor: pointer; + background-color: $carousel-indicator-active-bg; + background-clip: padding-box; + border: 0; + // Use transparent borders to increase the hit area by 10px on top and bottom. + border-top: $carousel-indicator-hit-area-height solid transparent; + border-bottom: $carousel-indicator-hit-area-height solid transparent; + opacity: $carousel-indicator-opacity; + @include transition($carousel-indicator-transition); + } + + .active { + opacity: $carousel-indicator-active-opacity; + } +} + + +// Optional captions +// +// + +.carousel-caption { + position: absolute; + right: (100% - $carousel-caption-width) * .5; + bottom: $carousel-caption-spacer; + left: (100% - $carousel-caption-width) * .5; + padding-top: $carousel-caption-padding-y; + padding-bottom: $carousel-caption-padding-y; + color: $carousel-caption-color; + text-align: center; +} + +// Dark mode carousel + +@mixin carousel-dark() { + .carousel-control-prev-icon, + .carousel-control-next-icon { + filter: $carousel-dark-control-icon-filter; + } + + .carousel-indicators [data-bs-target] { + background-color: $carousel-dark-indicator-active-bg; + } + + .carousel-caption { + color: $carousel-dark-caption-color; + } +} + +.carousel-dark { + @include carousel-dark(); +} + +@if $enable-dark-mode { + @include color-mode(dark) { + @if $color-mode-type == "media-query" { + .carousel { + @include carousel-dark(); + } + } @else { + .carousel, + &.carousel { + @include carousel-dark(); + } + } + } +} diff --git a/scss/bootstrap/_close.scss b/scss/bootstrap/_close.scss new file mode 100644 index 00000000..4d6e73c1 --- /dev/null +++ b/scss/bootstrap/_close.scss @@ -0,0 +1,63 @@ +// Transparent background and border properties included for button version. +// iOS requires the button element instead of an anchor tag. +// If you want the anchor version, it requires `href="#"`. +// See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile + +.btn-close { + // scss-docs-start close-css-vars + --#{$prefix}btn-close-color: #{$btn-close-color}; + --#{$prefix}btn-close-bg: #{ escape-svg($btn-close-bg) }; + --#{$prefix}btn-close-opacity: #{$btn-close-opacity}; + --#{$prefix}btn-close-hover-opacity: #{$btn-close-hover-opacity}; + --#{$prefix}btn-close-focus-shadow: #{$btn-close-focus-shadow}; + --#{$prefix}btn-close-focus-opacity: #{$btn-close-focus-opacity}; + --#{$prefix}btn-close-disabled-opacity: #{$btn-close-disabled-opacity}; + --#{$prefix}btn-close-white-filter: #{$btn-close-white-filter}; + // scss-docs-end close-css-vars + + box-sizing: content-box; + width: $btn-close-width; + height: $btn-close-height; + padding: $btn-close-padding-y $btn-close-padding-x; + color: var(--#{$prefix}btn-close-color); + background: transparent var(--#{$prefix}btn-close-bg) center / $btn-close-width auto no-repeat; // include transparent for button elements + border: 0; // for button elements + @include border-radius(); + opacity: var(--#{$prefix}btn-close-opacity); + + // Override 's hover style + &:hover { + color: var(--#{$prefix}btn-close-color); + text-decoration: none; + opacity: var(--#{$prefix}btn-close-hover-opacity); + } + + &:focus { + outline: 0; + box-shadow: var(--#{$prefix}btn-close-focus-shadow); + opacity: var(--#{$prefix}btn-close-focus-opacity); + } + + &:disabled, + &.disabled { + pointer-events: none; + user-select: none; + opacity: var(--#{$prefix}btn-close-disabled-opacity); + } +} + +@mixin btn-close-white() { + filter: var(--#{$prefix}btn-close-white-filter); +} + +.btn-close-white { + @include btn-close-white(); +} + +@if $enable-dark-mode { + @include color-mode(dark) { + .btn-close { + @include btn-close-white(); + } + } +} diff --git a/scss/bootstrap/_containers.scss b/scss/bootstrap/_containers.scss new file mode 100644 index 00000000..83b31381 --- /dev/null +++ b/scss/bootstrap/_containers.scss @@ -0,0 +1,41 @@ +// Container widths +// +// Set the container width, and override it for fixed navbars in media queries. + +@if $enable-container-classes { + // Single container class with breakpoint max-widths + .container, + // 100% wide container at all breakpoints + .container-fluid { + @include make-container(); + } + + // Responsive containers that are 100% wide until a breakpoint + @each $breakpoint, $container-max-width in $container-max-widths { + .container-#{$breakpoint} { + @extend .container-fluid; + } + + @include media-breakpoint-up($breakpoint, $grid-breakpoints) { + %responsive-container-#{$breakpoint} { + max-width: $container-max-width; + } + + // Extend each breakpoint which is smaller or equal to the current breakpoint + $extend-breakpoint: true; + + @each $name, $width in $grid-breakpoints { + @if ($extend-breakpoint) { + .container#{breakpoint-infix($name, $grid-breakpoints)} { + @extend %responsive-container-#{$breakpoint}; + } + + // Once the current breakpoint is reached, stop extending + @if ($breakpoint == $name) { + $extend-breakpoint: false; + } + } + } + } + } +} diff --git a/scss/bootstrap/_dropdown.scss b/scss/bootstrap/_dropdown.scss new file mode 100644 index 00000000..587ebb48 --- /dev/null +++ b/scss/bootstrap/_dropdown.scss @@ -0,0 +1,250 @@ +// The dropdown wrapper (`
`) +.dropup, +.dropend, +.dropdown, +.dropstart, +.dropup-center, +.dropdown-center { + position: relative; +} + +.dropdown-toggle { + white-space: nowrap; + + // Generate the caret automatically + @include caret(); +} + +// The dropdown menu +.dropdown-menu { + // scss-docs-start dropdown-css-vars + --#{$prefix}dropdown-zindex: #{$zindex-dropdown}; + --#{$prefix}dropdown-min-width: #{$dropdown-min-width}; + --#{$prefix}dropdown-padding-x: #{$dropdown-padding-x}; + --#{$prefix}dropdown-padding-y: #{$dropdown-padding-y}; + --#{$prefix}dropdown-spacer: #{$dropdown-spacer}; + @include rfs($dropdown-font-size, --#{$prefix}dropdown-font-size); + --#{$prefix}dropdown-color: #{$dropdown-color}; + --#{$prefix}dropdown-bg: #{$dropdown-bg}; + --#{$prefix}dropdown-border-color: #{$dropdown-border-color}; + --#{$prefix}dropdown-border-radius: #{$dropdown-border-radius}; + --#{$prefix}dropdown-border-width: #{$dropdown-border-width}; + --#{$prefix}dropdown-inner-border-radius: #{$dropdown-inner-border-radius}; + --#{$prefix}dropdown-divider-bg: #{$dropdown-divider-bg}; + --#{$prefix}dropdown-divider-margin-y: #{$dropdown-divider-margin-y}; + --#{$prefix}dropdown-box-shadow: #{$dropdown-box-shadow}; + --#{$prefix}dropdown-link-color: #{$dropdown-link-color}; + --#{$prefix}dropdown-link-hover-color: #{$dropdown-link-hover-color}; + --#{$prefix}dropdown-link-hover-bg: #{$dropdown-link-hover-bg}; + --#{$prefix}dropdown-link-active-color: #{$dropdown-link-active-color}; + --#{$prefix}dropdown-link-active-bg: #{$dropdown-link-active-bg}; + --#{$prefix}dropdown-link-disabled-color: #{$dropdown-link-disabled-color}; + --#{$prefix}dropdown-item-padding-x: #{$dropdown-item-padding-x}; + --#{$prefix}dropdown-item-padding-y: #{$dropdown-item-padding-y}; + --#{$prefix}dropdown-header-color: #{$dropdown-header-color}; + --#{$prefix}dropdown-header-padding-x: #{$dropdown-header-padding-x}; + --#{$prefix}dropdown-header-padding-y: #{$dropdown-header-padding-y}; + // scss-docs-end dropdown-css-vars + + position: absolute; + z-index: var(--#{$prefix}dropdown-zindex); + display: none; // none by default, but block on "open" of the menu + min-width: var(--#{$prefix}dropdown-min-width); + padding: var(--#{$prefix}dropdown-padding-y) var(--#{$prefix}dropdown-padding-x); + margin: 0; // Override default margin of ul + @include font-size(var(--#{$prefix}dropdown-font-size)); + color: var(--#{$prefix}dropdown-color); + text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer) + list-style: none; + background-color: var(--#{$prefix}dropdown-bg); + background-clip: padding-box; + border: var(--#{$prefix}dropdown-border-width) solid var(--#{$prefix}dropdown-border-color); + @include border-radius(var(--#{$prefix}dropdown-border-radius)); + @include box-shadow(var(--#{$prefix}dropdown-box-shadow)); + + &[data-bs-popper] { + top: 100%; + left: 0; + margin-top: var(--#{$prefix}dropdown-spacer); + } + + @if $dropdown-padding-y == 0 { + > .dropdown-item:first-child, + > li:first-child .dropdown-item { + @include border-top-radius(var(--#{$prefix}dropdown-inner-border-radius)); + } + > .dropdown-item:last-child, + > li:last-child .dropdown-item { + @include border-bottom-radius(var(--#{$prefix}dropdown-inner-border-radius)); + } + + } +} + +// scss-docs-start responsive-breakpoints +// We deliberately hardcode the `bs-` prefix because we check +// this custom property in JS to determine Popper's positioning + +@each $breakpoint in map-keys($grid-breakpoints) { + @include media-breakpoint-up($breakpoint) { + $infix: breakpoint-infix($breakpoint, $grid-breakpoints); + + .dropdown-menu#{$infix}-start { + --bs-position: start; + + &[data-bs-popper] { + right: auto; + left: 0; + } + } + + .dropdown-menu#{$infix}-end { + --bs-position: end; + + &[data-bs-popper] { + right: 0; + left: auto; + } + } + } +} +// scss-docs-end responsive-breakpoints + +// Allow for dropdowns to go bottom up (aka, dropup-menu) +// Just add .dropup after the standard .dropdown class and you're set. +.dropup { + .dropdown-menu[data-bs-popper] { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: var(--#{$prefix}dropdown-spacer); + } + + .dropdown-toggle { + @include caret(up); + } +} + +.dropend { + .dropdown-menu[data-bs-popper] { + top: 0; + right: auto; + left: 100%; + margin-top: 0; + margin-left: var(--#{$prefix}dropdown-spacer); + } + + .dropdown-toggle { + @include caret(end); + &::after { + vertical-align: 0; + } + } +} + +.dropstart { + .dropdown-menu[data-bs-popper] { + top: 0; + right: 100%; + left: auto; + margin-top: 0; + margin-right: var(--#{$prefix}dropdown-spacer); + } + + .dropdown-toggle { + @include caret(start); + &::before { + vertical-align: 0; + } + } +} + + +// Dividers (basically an `
`) within the dropdown +.dropdown-divider { + height: 0; + margin: var(--#{$prefix}dropdown-divider-margin-y) 0; + overflow: hidden; + border-top: 1px solid var(--#{$prefix}dropdown-divider-bg); + opacity: 1; // Revisit in v6 to de-dupe styles that conflict with
element +} + +// Links, buttons, and more within the dropdown menu +// +// `