diff --git a/addon/components/country-select.hbs b/addon/components/country-select.hbs index d6e56cd..830a027 100644 --- a/addon/components/country-select.hbs +++ b/addon/components/country-select.hbs @@ -1,6 +1,6 @@ -
+
{{country.emoji}} {{country.name}} -
\ No newline at end of file diff --git a/addon/components/country-select.js b/addon/components/country-select.js index 0679b37..ff95f38 100644 --- a/addon/components/country-select.js +++ b/addon/components/country-select.js @@ -4,7 +4,6 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { guidFor } from '@ember/object/internals'; import { task } from 'ember-concurrency'; -import { later } from '@ember/runloop'; export default class CountrySelectComponent extends Component { @service fetch; @@ -14,6 +13,10 @@ export default class CountrySelectComponent extends Component { @tracked value; @tracked id = guidFor(this); + get renderInPlace() { + return this.args.renderInPlace ?? true; + } + constructor(owner, { value = null, disabled = false }) { super(...arguments); this.disabled = disabled; @@ -23,10 +26,12 @@ export default class CountrySelectComponent extends Component { @task *fetchCountries(value = null) { try { - this.countries = yield this.fetch.get('lookup/countries', { columns: ['name', 'cca2', 'flag', 'emoji'] }); - if (value) { - this.selected = this.findCountry(value); - } + this.countries = yield this.fetch.get( + 'lookup/countries', + { columns: ['name', 'cca2', 'flag', 'emoji'] }, + { fromCache: true, expirationInterval: 1, expirationIntervalUnit: 'week' } + ); + this.selected = this.findCountry(value); } catch (error) { this.countries = []; } @@ -40,15 +45,8 @@ export default class CountrySelectComponent extends Component { } } - @action listenForInputChanges(element) { - later(() => { - const { value } = element; - - if (this.value !== value) { - this.value = value; - this.changed(value); - } - }, 100); + @action handleChange(el, [value]) { + this.selected = this.findCountry(value); } @action selectCountry(country) { diff --git a/addon/components/layout/resource/panel.hbs b/addon/components/layout/resource/panel.hbs index 00a1127..186188a 100644 --- a/addon/components/layout/resource/panel.hbs +++ b/addon/components/layout/resource/panel.hbs @@ -8,6 +8,7 @@ @fullHeight={{true}} @isResizable={{this.isResizable}} @width={{this.width}} + ...attributes > {{#if @headerComponent}} {{component diff --git a/addon/components/locale-selector-tray.hbs b/addon/components/locale-selector-tray.hbs index c2a28dc..d3753d0 100644 --- a/addon/components/locale-selector-tray.hbs +++ b/addon/components/locale-selector-tray.hbs @@ -16,10 +16,10 @@
- {{#if this.loadAvailableCountries.isRunning}} + {{#if this.language.loadAvailableCountries.isRunning}} {{else}} - {{#each-in this.availableLocales as |key country|}} + {{#each-in this.language.availableLocales as |key country|}}
diff --git a/addon/components/locale-selector-tray.js b/addon/components/locale-selector-tray.js index 976adf9..cda8f48 100644 --- a/addon/components/locale-selector-tray.js +++ b/addon/components/locale-selector-tray.js @@ -2,7 +2,6 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; -import { debug } from '@ember/debug'; import { task } from 'ember-concurrency'; import calculatePosition from 'ember-basic-dropdown/utils/calculate-position'; @@ -10,26 +9,8 @@ export default class LocaleSelectorTrayComponent extends Component { @service intl; @service fetch; @service media; - - /** - * Tracks all the available locales. - * - * @memberof LocaleSelectorComponent - */ + @service language; @tracked locales = []; - - /** - * All available countries data. - * - * @memberof LocaleSelectorComponent - */ - @tracked countries = []; - - /** - * The current locale in use. - * - * @memberof LocaleSelectorComponent - */ @tracked currentLocale; /** @@ -41,7 +22,6 @@ export default class LocaleSelectorTrayComponent extends Component { this.locales = this.intl.locales; this.currentLocale = this.intl.primaryLocale; - this.loadAvailableCountries.perform(); // Check for locale change this.intl.onLocaleChanged(() => { @@ -85,28 +65,6 @@ export default class LocaleSelectorTrayComponent extends Component { this.saveUserLocale.perform(selectedLocale); } - /** - * Loads available countries asynchronously. - * @returns {void} - * @memberof LocaleSelectorComponent - * @method loadAvailableCountries - * @instance - * @task - * @generator - */ - @task *loadAvailableCountries() { - try { - this.countries = yield this.fetch.get( - 'lookup/countries', - { columns: ['name', 'cca2', 'flag', 'emoji', 'languages'] }, - { fromCache: true, expirationInterval: 1, expirationIntervalUnit: 'week' } - ); - this.availableLocales = this._createAvailableLocaleMap(); - } catch (error) { - debug(`Locale Error: ${error.message}`); - } - } - /** * Saves the user's selected locale to the server. * @param {string} locale - The user's selected locale. @@ -120,45 +78,4 @@ export default class LocaleSelectorTrayComponent extends Component { @task *saveUserLocale(locale) { yield this.fetch.post('users/locale', { locale }); } - - /** - * Creates a map of available locales. - * @private - * @returns {Object} - The map of available locales. - * @memberof LocaleSelectorComponent - * @method _createAvailableLocaleMap - * @instance - */ - _createAvailableLocaleMap() { - const localeMap = {}; - - for (let i = 0; i < this.locales.length; i++) { - const locale = this.locales.objectAt(i); - - localeMap[locale] = this._findCountryDataForLocale(locale); - } - - return localeMap; - } - - /** - * Finds country data for a given locale. - * @private - * @param {string} locale - The locale to find country data for. - * @returns {Object|null} - The country data or null if not found. - * @memberof LocaleSelectorComponent - * @method _findCountryDataForLocale - * @instance - */ - _findCountryDataForLocale(locale) { - const localeCountry = locale.split('-')[1]; - const country = this.countries.find((country) => country.cca2.toLowerCase() === localeCountry); - - if (country) { - // get the language - country.language = Object.values(country.languages)[0]; - } - - return country; - } } diff --git a/addon/components/overlay.js b/addon/components/overlay.js index abf6163..8e76dca 100644 --- a/addon/components/overlay.js +++ b/addon/components/overlay.js @@ -28,6 +28,7 @@ export default class OverlayComponent extends Component { isOpen: this.isOpen, isMinimized: this.isMinimized, isMaximized: this.isMaximized, + overlayNode: this.overlayNode, }; @action setupComponent(element) { diff --git a/addon/components/pill.hbs b/addon/components/pill.hbs index 7835c55..5a18a71 100644 --- a/addon/components/pill.hbs +++ b/addon/components/pill.hbs @@ -26,23 +26,25 @@ {{#if (has-block)}} {{yield @resource}} {{else}} -
{{n-a @title this.resourceName}}
+
{{or @title this.resourceName @titleFallback "-"}}
{{#if @subtitle}}
{{n-a @subtitle}}
{{/if}} {{yield @resource}} {{/if}}
- {{#if (has-block "tooltip")}} - - - {{yield @resource to="tooltip"}} - - - {{else if @tooltipComponent}} - - {{component @tooltipComponent}} - - {{/if}} + {{#unless @noTooltip}} + {{#if (has-block "tooltip")}} + + + {{yield @resource to="tooltip"}} + + + {{else if @tooltipComponent}} + + {{component @tooltipComponent}} + + {{/if}} + {{/unless}}
\ No newline at end of file diff --git a/addon/components/pill.js b/addon/components/pill.js index c630e75..94067a4 100644 --- a/addon/components/pill.js +++ b/addon/components/pill.js @@ -6,7 +6,7 @@ export default class PillComponent extends Component { /* eslint-disable ember/no-get */ get resourceName() { const record = this.args.resource; - if (!record) return 'resource'; + if (!record) return null; return ( get(record, this.args.namePath ?? 'name') ?? diff --git a/addon/utils/dom.js b/addon/utils/dom.js index a6d1217..d2b1748 100644 --- a/addon/utils/dom.js +++ b/addon/utils/dom.js @@ -2,6 +2,7 @@ import { getOwner } from '@ember/application'; import { DEBUG } from '@glimmer/env'; import { warn } from '@ember/debug'; import { schedule } from '@ember/runloop'; +import { isArray } from '@ember/array'; import { all } from 'rsvp'; import requirejs from 'require'; @@ -166,3 +167,92 @@ export function waitForInsertedAndSized(getElOrEl, { timeoutMs = 4000 } = {}) { } }); } + +/** + * Create a DOM element with declarative options. + * + * @param {string} tag + * @param {Object} [options] + * @param {string|string[]|Node|Node[]} [children] + * @returns {HTMLElement} + */ +export function createElement(tag, options = {}, children = null) { + const el = document.createElement(tag); + + // ---------- Classes ---------- + if (options.classNames) { + const classes = isArray(options.classNames) ? options.classNames : options.classNames.split(' '); + el.classList.add(...classes.filter(Boolean)); + } + + // ---------- Styles ---------- + if (options.styles && typeof options.styles === 'object') { + Object.assign(el.style, options.styles); + } + + // ---------- Attributes ---------- + if (options.attrs && typeof options.attrs === 'object') { + for (const [key, value] of Object.entries(options.attrs)) { + if (value !== false && value != null) { + el.setAttribute(key, value === true ? '' : value); + } + } + } + + // ---------- Dataset ---------- + if (options.dataset && typeof options.dataset === 'object') { + for (const [key, value] of Object.entries(options.dataset)) { + el.dataset[key] = value; + } + } + + // ---------- Event listeners ---------- + if (options.on && typeof options.on === 'object') { + for (const [event, handler] of Object.entries(options.on)) { + if (typeof handler === 'function') { + el.addEventListener(event, handler); + } + } + } + + // ---------- Text / HTML (exclusive) ---------- + const hasText = options.text != null || options.innerText != null; + const hasHtml = options.html != null || options.innerHTML != null; + + if (hasText && hasHtml) { + throw new Error('createElement: use either text OR html, not both.'); + } + + if (hasText) { + el.textContent = options.text ?? options.innerText; + } else if (hasHtml) { + el.innerHTML = options.html ?? options.innerHTML; + } else { + // ---------- Children ---------- + const append = (child) => { + if (child == null) return; + if (Array.isArray(child)) return child.forEach(append); + if (child instanceof Node) el.appendChild(child); + else el.appendChild(document.createTextNode(String(child))); + }; + + append(children); + } + + // ---------- Mount ---------- + if (options.mount) { + let mountTarget = options.mount; + + if (typeof mountTarget === 'string') { + mountTarget = document.querySelector(mountTarget); + } + + if (mountTarget instanceof Element) { + mountTarget.appendChild(el); + } else { + console.warn('createElement: mount target not found', options.mount); + } + } + + return el; +} diff --git a/addon/utils/floating.js b/addon/utils/floating.js new file mode 100644 index 0000000..9dfed25 --- /dev/null +++ b/addon/utils/floating.js @@ -0,0 +1,81 @@ +import { computePosition, offset, flip, shift } from '@floating-ui/dom'; +import { isArray } from '@ember/array'; +import { createElement } from './dom'; + +export class Tooltip { + mountEl; + tooltipEl; + options; + cleanupFns = []; + + constructor(mountEl, options = {}) { + this.mountEl = mountEl; + this.options = options; + this.#setup(); + } + + #setup() { + const { text, classNames = [], placement = 'top', offset: offsetValue = 5 } = this.options; + + const classes = isArray(classNames) ? classNames : String(classNames).split(' '); + + this.tooltipEl = createElement('div', { + classNames: ['ui-input-info', 'text-xs', ...classes], + text, + attrs: { + role: 'tooltip', + }, + styles: { + position: 'absolute', + width: 'max-content', + zIndex: 777, + opacity: 0, + pointerEvents: 'none', + transition: 'opacity 0.15s ease', + }, + mount: document.body, + }); + + const show = async () => { + const { x, y } = await computePosition(this.mountEl, this.tooltipEl, { + placement, + middleware: [offset(offsetValue), flip(), shift({ padding: 8 })], + }); + + Object.assign(this.tooltipEl.style, { + left: `${x}px`, + top: `${y}px`, + opacity: 1, + }); + }; + + const hide = () => { + this.tooltipEl.style.opacity = 0; + }; + + this.mountEl.addEventListener('mouseenter', show); + this.mountEl.addEventListener('mouseleave', hide); + this.mountEl.addEventListener('focus', show); + this.mountEl.addEventListener('blur', hide); + + // cleanup tracking + this.cleanupFns.push(() => { + this.mountEl.removeEventListener('mouseenter', show); + this.mountEl.removeEventListener('mouseleave', hide); + this.mountEl.removeEventListener('focus', show); + this.mountEl.removeEventListener('blur', hide); + this.tooltipEl.remove(); + }); + } + + destroy() { + this.cleanupFns.forEach((fn) => fn()); + this.cleanupFns = []; + } +} + +export default { + createTooltip() { + return new Tooltip(...arguments); + }, +}; diff --git a/app/utils/floating.js b/app/utils/floating.js new file mode 100644 index 0000000..c52b324 --- /dev/null +++ b/app/utils/floating.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/utils/floating'; diff --git a/package.json b/package.json index 983af3b..5d9b5a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-ui", - "version": "0.3.14", + "version": "0.3.15", "description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.", "keywords": [ "fleetbase-ui", diff --git a/tests/unit/utils/floating-test.js b/tests/unit/utils/floating-test.js new file mode 100644 index 0000000..3cba686 --- /dev/null +++ b/tests/unit/utils/floating-test.js @@ -0,0 +1,10 @@ +import floating from 'dummy/utils/floating'; +import { module, test } from 'qunit'; + +module('Unit | Utility | floating', function () { + // TODO: Replace this with your real tests. + test('it works', function (assert) { + let result = typeof floating === 'object' && typeof floating.createTooltip === 'function'; + assert.ok(result); + }); +});