notifications

TosiNotification provides a singleton custom <tosi-notification> element that manages a list of notifications.

Notifications stack up from the bottom of the screen, newest on top. Up to TosiNotification.maxVisible (3) they sit in a column. Past that, the newest three stay in view and the lowest of them becomes the front of a stack: older notifications sit underneath it, newest first, each one's lower edge showing below the card in front by TosiNotification.stackOffsets ([16, 8, 4], in px: each peeks out half as far as the last). Any beyond that are hidden until the ones in front are closed. Close the front card and the next one comes forward.

When more than one notification is showing, a Dismiss all button sits centred at the bottom, over the edges of any stacked cards, outlined in the colour of the most severe notification open, hidden ones included. It closes every notification, firing each one's close callback as if the user had closed it; TosiNotification.dismissAll() does the same from code.

A long message is cut off with an ellipsis after four lines (--notification-max-lines), so the close button always stays in view; the full text is the notification's tooltip, and screen readers get all of it.

You can post a notification simply with TosiNotification.post() or postNotification().

interface NotificationSpec {
  message: string
  type?: 'success' | 'info' | 'log' | 'warn' | 'error' | 'progress' // default 'info'
  icon?: SVGElement | string // defaults to an info icon
  duration?: number
  progress?: () => number    // return percentage completion
  close?: () => void
  color?: string             // specify color
}

If you provide a progress callback (which is assumed to return a number from 0-100, with 100+ indicating completion) then TosiNotification will poll it every second until the task completes or the notification is closed. Returning 100 or more will automatically close the notification.

If you configure a notification's type = "progress" but don't provide a progress callback then an indefinite <progress> element will be displayed.

If you provide a close callback, it will be fired if the user closes the notification.

postNotification returns a callback function that closes the note programmatically (e.g. when an operation completes). This will also call any close callback function you provided. (The progress demos in the example exercise this functionality.)

import { postNotification, TosiNotification, icons } from 'tosijs-ui'

const form = preview.querySelector('tosi-form')
const submit = preview.querySelector('.submit')
const closeButton = preview.querySelector('.close')

let close
// read the field itself: the form's value omits a field nobody has edited
const maxVisible = () =>
  Number(preview.querySelector('tosi-field[key=maxVisible] input').value) || 3

form.submitCallback = (value, isValid) => {
  if (!isValid) return
  TosiNotification.maxVisible = maxVisible()
  if (value.type.startsWith('progress')) {
    startTime = Date.now()
    const { message, duration, icon } = value
    close = postNotification({
      message,
      type: 'progress',
      icon,
      progress: value.type === 'progress' ? () => (Date.now() - startTime) / (10 * duration) : undefined,
      close: () => { postNotification(`${value.message} cancelled`) },
    })
  } else {
    close = postNotification(value)
  }
  closeButton.disabled = false
}

submit.addEventListener('click', form.submit)
preview.querySelector('.burst').addEventListener('click', () => {
  TosiNotification.maxVisible = maxVisible()
  const types = ['error', 'warn', 'info', 'success', 'log']
  for (let i = 1; i <= 8; i++) {
    postNotification({ message: `Notification ${i} of 8`, type: types[i % types.length] })
  }
})
closeButton.addEventListener('click', () => {
  if (close) {
    close()
  }
})

postNotification({
  message: 'Welcome to tosijs-ui notifications, this message will disappear in 2s',
  duration: 2
})
<tosi-form>
  <h3 slot="header">Notification Test</h3>
  <tosi-field caption="Message" key="message" type="string" value="This is a test…"></tosi-field>
  <tosi-field caption="Type" key="type" value="info">
    <tosi-select slot="input"
      options="error,warn,info,success,log,,progress,progress (indefinite)"
    ></tosi-select>
  </tosi-field>
  <tosi-field caption="Icon" key="icon" value="info">
    <tosi-select slot="input"
      options="info,bug,thumbsUp,thumbsDown,message,spin120Loader"
    ></tosi-select>
  </tosi-field>
  <tosi-field caption="Duration" key="duration" type="number" value="2"></tosi-field>
  <tosi-field caption="Max visible" key="maxVisible" type="number" value="3"></tosi-field>
  <button slot="footer" class="close" disabled>Close Last Notification</button>
  <button slot="footer" class="burst">Post 8</button>
  <span slot="footer" class="elastic"></span>
  <button slot="footer" class="submit">Post Notification</button>
</tosi-form>
tosi-form {
  height: 100%;
}

tosi-form::part(content) {
  display: flex;
  flex-direction: column;
  padding: 10px;
  gap: 10px;
  background: var(--background);
}

tosi-form::part(header),
tosi-form::part(footer) {
  background: #eee;
  justify-content: center;
  padding: 10px;
}

tosi-form h3 {
  margin: 0;
}

tosi-form label {
  display: grid;
  grid-template-columns: 120px 1fr;
}
test('notification singleton exists', async () => {
  await waitMs(100)
  const notification = document.querySelector('tosi-notification')
  expect(notification).toBeTruthy()
})
test('form renders', () => {
  const form = preview.querySelector('tosi-form')
  expect(form).toBeTruthy()
})

postNotification(spec: NotificationSpec | string)

This is simply a wrapper for TosiNotification.post().