Showing posts with label CSS. Show all posts
Showing posts with label CSS. Show all posts

Monday, 20 January 2025

wc-trim-middle

I've lost count of the number of times I've needed to truncate text online, and I've tried all sorts of mechanisms, so when I came across Christian Heilmann's trimMiddle() function, I was happy as Larry.

So happy that I just had to convert it into a web component. After reading about how problematic people are finding styling web components from outside, I also decided to create it without a shadow DOM, and I'm pleased with the result.

There's a handy demo, and I have to say that getting it to respect changes to the inner text dynamically was an utter PITA! Have a play and see if it fits your requirements.

Friday, 15 November 2024

Container queries and column layouts.

We've recently introduced the possibility of new starters borrowing some of our old kit. A couple of factors have prompted this: we've found that beginners prefer using wooden takedown bows rather than the fancy aluminium ILF kit we purchased a few years - we don't know why; the feel of wood is nicer I guess, and the weight of the bow is lighter (people seem less inclined to lift heavy weights after the pandemic) - the other reason is that we can't be bothered popping stuff onto eBay and figuring out postage and all that stuff.

This means we need a way of recording our kit and who has it, so rather than killing another spreadsheet, I've added it to our web application. Using React Bootstrap, we have cards available, so each kit has a card, sometimes with images. But displaying the details of the kit is awkward. Adding and editing are easy, as I'm a fan of simple forms, but showing it is less straightforward and likely to take up a lot of space, so I thought about using CSS columns; so far, so good!

But it's responsive, so we need to alter the number of columns depending on the screen's width—which isn't helped by my adding a sidebar on broader screens. When the screen hit a specific width and the menu switched to the side, we were left with a hinterland situation when I used media queries on the whole document. What was once extensive in the main content ended up shrinking, thus affecting the columns' layout: I needed container queries, something I read about a little while ago and bookmarked.

After reading the article by Josh W Comeau (A Friendly Introduction to Container Queries) I knew how to do it too:

.responsive_multi_columns {
  container-type: inline-size;
  .columns {
    column-count: 1;
    column-gap: 0;
    dd {
      break-before: avoid-column;
    }
    @container (min-width: 36rem) { // 576px (sm)
      column-count: 2;
      column-gap: .25rem;
    }
    @container (min-width: 48rem) { // 768px (md)
      column-count: 3;
      column-gap: .5rem;
    }
    @container (min-width: 60rem) { // 960px (lg)
      column-count: 4;
      column-gap: 1rem;
    }
    @container (min-width: 75rem) { // 1200px (xl)
      column-count: 5;
      column-gap: 2rem;
    }
    @container (min-width: 87.5rem) { // 1400px (xxl)
      column-count: 6;
      column-gap: 2rem;
    }
  }
}

What was annoying, though, was the tendency for the definition list to break at what I found to be wrong places; this was solved by adding break-before: avoid-column; to the dd element - that worked a treat!

Saturday, 15 July 2023

I’ve recently been updating a website and trying to implement a data of birth field; what do you think is the best input type for such a field?

<input type="date" id="dob" name="dob" />

I thought a date type input would be best, but then I tried entering the date of birth of a lady joining the club on my phone, and I gave up! I asked her to email it to me as it was getting embarrassing scrolling through all the months to the 1970s – see, she wasn’t even as old as me, and I thought about how annoying it is to have to scroll to enter my date of birth!

At least on Android, you must swipe through each month in each year, going back in time to that halcyon time when you were first spawned. 50 years of 12 months are 600 swipes through your history, and let me tell you, that gets depressing fast!

Anyway, I thought about how it’s managed elsewhere and found that the UK Government’s implementation (also used by the NHS) and the GOV.UK Design System has a lovely mechanism for date inputs with three separate fields (one for the day, one for the month and one for the year). It’s located here, but the page tends to break. The NHSs version doesn’t break, though – and they acknowledge its ancestry.

As a fan of web components, I thought I’d implement the same thing, add it to our website, and allow others to take advantage of it as well. I built-in date validation using the native date object rather than Moment.js or date-fns, as I’ve played before with using dropdown fields to enter the values in a Vue component in the dim and distant past – that was fun though as the dropdown values would only allow valid options to be selected. In that component, you could only choose leap years if you had selected the 29th of February, for instance.

What I was particularly pleased about was the ability to add native form validation, which is something I’ve never tried before but was quite easy to implement. I’ve utilised significant manual testing and asked a mate to do some Selenium testing – but unit testing within web components seems to be something of a dark art, especially if not using a framework to create the component – and why would you use a framework if you’re avoiding frameworks and writing native web components?

There’s a CODEPEN to play with here, and it’s on npm so that you can play with it too – if you spot anything, please let me know; hey, tell me your thoughts anyway! I’ve used it within a React application, and it also seems to work well there.

Friday, 30 September 2022

wc-slider (An alternative to the range input)

HTML is brilliant; it's an ever-growing standard; CSS is also pretty amazing! I say this because I was bored over the evenings this week and brushed off an old thing I started years ago to help me with the inability to style range inputs. You can do some cool styling on range inputs now, but when I created my initial component, it wasn't easy and involved many vendor prefixes, with some target browsers unable to handle even those. So I decided to write a component to get our designer's desired effect.

But I was bored this week, so I decided to revisit it; you can see the result on JSFiddle.

It was a lot of fun doing this, I'm not sure if I'll ever need it again, but it was a valuable learning experience. Asking for help from a mate meant that she could echo back something I've said to her many times before: "Have you tried flex?", so embarrassing, but it just goes to show! One issue was that I was sticking to the original CSS too much and didn't have to cope with variable-sized wc-sliders. You see, the little arrows behind the slider element moved depending on the number of elements in the slider.

The Drag-and-Drop mechanism took the most time and - as a result of checking my workings - I clocked I was using the getters for range, colourRange and deselectedRange far too much. I moved these to Private class features and only calculate them on render(). Thanks to the dictates of working with a designer, I also spent a fair bit of effort working with colours and gradients - a bit of a ballache, TBH, but I've got the functions now so that I can use them again in the future!

I do like the invertColor(hex, bw) though. That's tremendous fun and might be useful should you check what colour you need to set text atop different coloured backgrounds. Looking at it again now, though, I think the none bw might be a little borked; I'll fix it as and when I've time.

It's a bit CSS-heavy, but no worse for that, TBH; if you can do something with CSS, then it's better than firing up JS - let the browser do the work!

From the README:

  • Dragging the slider element will alter the value surfaced by the component from its value attribute (should the number in which the drop ends be greater than or equal to the constrain-min or less than or equal to the constrain-max)
  • Clicking on the numbers shown at the top of each step will alter the value surfaced by the component from its value attribute (should the number be greater than or equal to the constrain-min or less than or equal to the constrain-max).
  • Clicking on the triangles on either side of the slider will alter the value surfaced by the component from its value attribute by +/- 1 (should the number resulting from the click be greater than or equal to the constrain-min or less than or equal to the constrain-max).

As you can doubtless see, there are three ways of changing the value and - given the work I put into checking the bugger, no way for an invalid value to be produced - though you might not like the value. Having said that, if you manage to break it, please let me know, so I can fix it!

It has some default attributes so that you can test it for your use case.

Again, from the README:

  • The range of numbers, inclusive of min and max, should not be too large - tests have found that the ranges should not have more than 15 numbers, but YMMV.

One thing to consider in the future is whether or not to allow for a comma-separated list of hex values so that designers can adequately define what colours should appear on the wc-slider.

Wednesday, 21 September 2022

Fixing the Donut

I've been playing with a pie-chart web component for a little while now, and I was thrilled with the result... until I started to use it in anger, that is.

The thing was, angles over 180 messed something up - I decided to revisit it this week after reading this article by Temani Afif, and I'm once again thrilled to see it working - and working correctly (at least in Chrome and Edge):

class DMPieChart extends HTMLElement {
  static get observedAttributes() {
    return [
      'width',    // width of the chart
      'duration', // duration of the animation
      'delay',    // delay before the animation
      'thickness' // thickness of the slices
    ];
  }
  get style() {
    return `
      <style>
        * {
          box-sizing: border-box;
        }
        div {
          width: ${this.width}px;
          height: ${this.width}px;
          position: relative;
        }
      </style>
    `
  }
  constructor() {
    super()
    this.shadow = this.attachShadow({
      mode: 'closed'
    })
    this.shadow.innerHTML = `
      ${this.style}
      <div>
        <slot name='pie-slices'></slot>
      </div>
    `
    try {
      window.CSS.registerProperty({
        name: '--p',
        syntax: '<number>',
        inherits: true,
        initialValue: 0,
      })
    }catch (e) {
      console.warn('Browser does not support animated conical gradients')
    }
  }
  connectedCallback() {
    const segments = [...this.querySelectorAll('dm-pie-slice')]
    const total = segments.reduce((p, c) => p + Number(c.getAttribute('value')), 0)
    let durationTotal = this.delay;
    let rotationTotal = 0
    const totalDegree = 360/total

    segments.forEach(segment => {
      const value = Number(segment.getAttribute('value'))
      const currentRotation = totalDegree * value
      const animationDuration = currentRotation / (360/Number(this.duration))
      segment.setAttribute('thickness', this.thickness * this.width)
      segment.setAttribute('end', (value / total) * 100)
      segment.setAttribute('rotate', rotationTotal)
      segment.setAttribute('delay', durationTotal)
      segment.setAttribute('duration', animationDuration)
      segment.setAttribute('width', this.width)
      rotationTotal += currentRotation
      durationTotal += animationDuration
    })
  }
  get width() {
    return Number(this.getAttribute('width')) || 150      // 150px by default
  }
  get duration() {
    return Number(this.getAttribute('duration')) || 5000  // 5 seconds by default
  }
  get delay() {
    return Number(this.getAttribute('delay')) || 500      // half a second by default
  }
  get thickness() {
    return Number(this.getAttribute('thickness')) || 0.2   // 60% of width by default
  }
}

class DMPieSlice extends HTMLElement{
  static get observedAttributes() {
    return [
      'width',      // width of the chart
      'duration',   // duration of the animation
      'delay',      // delay before the animation
      'color',      // color of the arc
      'thickness',   // thickness of the arc
      'rotate'      // how far to rotate the arc
    ];
  }
  get style() {
    return `
      <style>
        * {
          box-sizing: border-box;
        }
        div {
          --p: ${this.end};
          width: ${this.width}px;
          aspect-ratio: 1;
          margin: 0;
          position: absolute;
          left: 50%;
          top: 50%;
          animation-name: p;
          animation-fill-mode: both;
          animation-timing-function: ease-in-out;
          transform: translate(-50%, -50%);
          animation-duration: ${this.duration}ms;
          animation-delay: ${this.delay}ms;
        }
        div:before {
          content: "";
          position: absolute;
          border-radius: 50%;
          inset: 0;
          background: conic-gradient(from ${this.rotate}deg, ${this.color} calc(var(--p)*1%), transparent 0);
          -webkit-mask: radial-gradient(farthest-side, transparent calc(99% - ${this.thickness}px), #000 calc(100% - ${this.thickness}px));
          mask: radial-gradient(farthest-side,#0000 calc(99% - ${this.thickness}px), #000 calc(100% - ${this.thickness}px));
        }
        @keyframes p {
          from {
            --p: 0
          }
        }
      </style>
    `
  }
  constructor() {
    super();
    this.shadow = this.attachShadow({
      mode: 'closed'
    })
    this.shadow.innerHTML = `${this.style}<div/>`
  }
  get width() {
    return Number(this.getAttribute('width')) || 150      // 150px by default
  }
  get duration() {
    return Number(this.getAttribute('duration'))
  }
  get delay() {
    return Number(this.getAttribute('delay'))
  }
  get color() {
    return this.getAttribute('color') || '#000000'        // black by default
  }
  get thickness() {
    return Number(this.getAttribute('thickness'))
  }
  get rotate() {
    return Number(this.getAttribute('rotate'))
  }
  get end() {
    return Number(this.getAttribute('end'))
  }
}

window.customElements.define('dm-pie-chart', DMPieChart)
window.customElements.define('dm-pie-slice', DMPieSlice)

It is significantly cleaner but only animates within Chrome and Edge (everywhere else, it should just appear without animation, which is a shame).

I've also moved away from using the CSSStyleSheet interface as Safari throws a wobble when it's used without a polyfill, which is a shame as constructible/adoptable style sheets rock!

The animation is thanks to @property CSS at-rule. It is utterly brilliant, especially once I understood you could inject it via JS, as injecting it into the component's CSS didn't work at all - either via the static CSS or the constructible/adoptable CSS.

Bundling it into one file should also ease its adoption.

Monday, 12 September 2022

Sorting colours is a pain!

I've written before about sorting colours, and this got me thinking about some work I'm doing in terms of converting some CSS to SCSS (with the proper use of CSS variables, I must add).

As such, I've been wading through some CSS, extracting colours, and then popping them into a :root CSS pseudo-class. I do this by looking for `#` and popping the colour into the function as and when I find them. The resulting text is well messy! I thought it couldn't be that hard to sort them similarly as employed in Google Sheets; so I came up with CSSColourSorter, which you're more than welcome to use or steal/borrow as you see fit.

It works by pasting in your whole :root directive - you'll then need to copy and paste the resulting test into your CSS/SCSS as noted before, though, sorting colours is a pain!

I've added a swatch beneath the textareas so you can see the new spectrum - mainly to check that I was doing things right!

Monday, 20 June 2022

(Doughnut) Donut Chart Web Component

About three months back I saw a CodePen by Hilario Goes and I was inspired to convert it into a Web Component. That I did but it ended up being really messy:

<wc-donut-chart id="test"
                part-1-value="5"
                part-1-name="Part 1"
                part-1-color="#E64C65"
                part-2-value="5"
                part-2-name="Part 2"
                part-2-color="#11A8AB"
                part-3-value="5"
                part-3-name="Part 3"
                part-3-color="#4FC4F6"
                animation-duration="3"
                hole-color="#394264"></wc-donut-chart>

(you can see it in action if you download the repo and run index.html.)

It worked, but it relied upon a mechanism I developed a little while back for injecting CSS into components, and the code was all over the place. It also went against a best practice I read a little while back regarding components doing too much.

The segments of the doughnut chart didn't need to be created by the doughnut but could - instead - be their own concern - so I made the dm-donut-part Component:

(() => {
  const mainSheet = new CSSStyleSheet()
  mainSheet.replaceSync(`
    :host { 
      --end: 20deg;
    }
    * {
      box-sizing: border-box;
    }
    .segment-holder {
      border-radius: 50%;
      clip: rect(0, var(--dimension), var(--dimension), calc(var(--dimension) * 0.5));
      height: 100%;
      position: absolute;
      width: 100%;
    }
    .segment {
      border-radius: 50%;
      clip: rect(0, calc(var(--dimension) * .5), var(--dimension), 0);
      height: 100%;
      position: absolute;
      width: 100%;
      font-size: 1.5rem;
      animation-fill-mode: forwards;
      animation-iteration-count: 1;
      animation-timing-function: ease;
      animation-name: rotate;
    }
    @keyframes rotate {
      from {
        transform: rotate(0deg);
      }
      to {
        transform: rotate(var(--end));
      }
    }
  `)

  class DonutPart extends HTMLElement {
    static get observedAttributes() {
      return [
        'name',
        'color',
        'rotate',
        'duration',
        'start'
      ];
    }
    constructor() {
      super()
      this.shadow = this.attachShadow({
        mode: 'open'
      })
      this.shadow.adoptedStyleSheets = [mainSheet];
      this.shadow.innerHTML = `
        <div class="segment-holder">
          <div class="segment"></div>
        </div>
      `
      this.render()
    }
    render() {
      const sheet = new CSSStyleSheet()
      sheet.replaceSync( `
        :host { 
          --end: ${this.end};
        }
        .segment-holder {
          transform: rotate(${this.rotate});
        }
        .segment {
          background-color: ${this.color};
          animation-delay: ${this.delay};
          animation-duration: ${this.duration};
        }
      `)
      this.shadowRoot.adoptedStyleSheets = [mainSheet, sheet]
    }

    get end() {
      return this.getAttribute('end') || '120deg'
    }
    get color() {
      return this.getAttribute('color') || '#000000'
    }
    get delay() {
      return this.getAttribute('delay') || '0s'
    }
    get duration() {
      return this.getAttribute('duration') || '0s'
    }
    get rotate() {
      return this.getAttribute('rotate') || '0deg'
    }
    get title() {
      return this.getAttribute('title') || null
    }
    attributeChangedCallback(name, oldValue, newValue) {
      if((oldValue !== newValue)){
        this.render()
      }
    }
  }
  window.customElements.define('dm-donut-part', DonutPart)
})()

These parts are injected into the parent dm-donut-chart in a slot and the parent dm-donut-chart interogates them in order to populate their attributes.

(() => {
  const mainSheet = new CSSStyleSheet()
  mainSheet.replaceSync(`
    :host {
      --dimension: 200px;
    }
    * {
      box-sizing: border-box;
    }
    .donut-chart {
      position: relative;
      width: var(--dimension);
      height: var(--dimension);
      margin: 0 auto;
      border-radius: 100%
    }
    .center {
      position: absolute;
      top:0;
      left:0;
      bottom:0;
      right:0;
      width: calc(var(--dimension) * .65);
      height: calc(var(--dimension) * .65);
      margin: auto;
      border-radius: 50%;
    }
  `)
  class DonutChart extends HTMLElement {
    static get observedAttributes() {
      return [
        'duration',
        'color',
        'delay',
        'diameter',
        'dimension'
      ];
    }
    constructor() {
      super()
      this.shadow = this.attachShadow({
        mode: 'open'
      })
      this.shadow.adoptedStyleSheets = [mainSheet];
      this.shadow.innerHTML = `
        <div class="donut-chart">
          <slot name='segments'></slot>
          <div class="center"></div>
        </div>
      `
      this.render()
    }
    render() {
      const segments = [...this.querySelectorAll('dm-donut-part')]
      const total = segments.reduce((p, c) => p + Number(c.getAttribute('value')), 0)
      let durationTotal = this.delay;
      let rotationTotal = 0
      const totalDegree = 360/total
      segments.forEach(segment => {
        const currentRotation = totalDegree * Number(segment.getAttribute('value'))
        const animationDuration = currentRotation / (360/Number(this.duration))
        segment.setAttribute('end', `${currentRotation}deg`)
        segment.setAttribute('rotate', `${rotationTotal}deg`)
        segment.setAttribute('delay', `${durationTotal}s`)
        segment.setAttribute('duration', `${animationDuration}s`)
        rotationTotal += currentRotation
        durationTotal += animationDuration
      })
      const sheet = new CSSStyleSheet()
      sheet.replaceSync( `
        :host {
          --dimension: ${this.dimension}px;
        }
        .center {
          background-color: ${this.color};
          width: calc(var(--dimension) * ${this.diameter});
          height: calc(var(--dimension) * ${this.diameter});
        }
      `)
      this.shadowRoot.adoptedStyleSheets = [mainSheet, sheet]
    }
    get color() {
      return this.getAttribute('color') || '#000000'
    }
    get duration() {
      return Number(this.getAttribute('duration')) || 4.5
    }
    get delay() {
      return Number(this.getAttribute('delay')) || 0
    }
    get diameter() {
      return Number(this.getAttribute('diameter')) || .65
    }
    get dimension() {
      return Number(this.getAttribute('dimension')) || 200
    }
  }
  window.customElements.define('dm-donut-chart', DonutChart)
})()

A much cleaner approach, and it allowed me to slim down the code and remove the need for the DomHelpers (though I do love them).

I also got a chance to play with replaceSync, which is brilliant and even works on Safari with a suitable polyfill.

This is how I invoke the doughnut:

<dm-donut-chart color="#394264"
                duration="4.5"
                delay="2"
                diameter=".6"
                dimension="200">
  <div slot="segments">
    <dm-donut-part color="#E64C65"
                   value="5"></dm-donut-part>
    <dm-donut-part color="#11A8AB"
                   value="5"></dm-donut-part>
    <dm-donut-part color="#4FC4F6"
                   value="5"></dm-donut-part>
  </div>
</dm-donut-chart>

I'm still not overly happy with having to have a hole within the doughnut - I'll do some more work and see if I can use arcs instead of squares - but I guess, without the hole, the doughnut chart acts like a regular pie chart.